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 b5cff239d feat(rest)!: make SigV4 backend optional (#2060)
b5cff239d is described below
commit b5cff239dc63e05f6af491517cf0ed0b132a1268
Author: Andrei Tserakhau <[email protected]>
AuthorDate: Fri Oct 2 12:58:08 2026 +0200
feat(rest)!: make SigV4 backend optional (#2060)
* feat(rest): make SigV4 backend optional
Move AWS SigV4 signing out of `catalog/rest` into `catalog/rest/sigv4`, so
plain REST users no longer pull in the AWS SDK.
Breaking change: `rest.WithAwsConfig` is removed. Use
`sigv4.WithAwsConfig(...)` and add a blank import of `catalog/rest/sigv4` to
enable SigV4 signing.
Follows the driver pattern established for cloud IO in #696.
* fix(rest): resolve SigV4 signing scope through a signer factory
Addresses review feedback on #2060.
* Update website/src/configuration.md
Co-authored-by: Matt Topol <[email protected]>
* feat(rest): sign SigV4 with catalog-property credentials via the backend
Carry the property-credential signing from #1999 into the optional sigv4
backend, now that catalog/rest no longer links the AWS SDK.
SignerConfig gains a Props field holding the post-override catalog
properties,
and resolveSigner passes opts.additionalProps through it.
staticCredsFromProps
and the rest.* alias keys move into catalog/rest/sigv4, where the registered
signer factory builds a static credentials provider from the s3.* / rest.*
properties and falls back to the AWS default chain. An explicit
sigv4.WithAwsConfig fully specifies the credentials and never consults the
properties.
The cross-origin signing guard stays in core RoundTrip: it is
signer-agnostic,
so it now also covers a signer installed verbatim via WithSigner. Tests move
with the code - the staticCredsFromProps table and the props-credential
end-to-end test live in the sigv4 package, while the cross-origin redirect
test
stays in core and drives the guard through a marking signer.
---------
Co-authored-by: Matt Topol <[email protected]>
---
catalog/rest/options.go | 36 +-
catalog/rest/rest.go | 133 +------
catalog/rest/rest_internal_test.go | 604 +++++++++---------------------
catalog/rest/signer.go | 133 +++++++
catalog/rest/sigv4/sigv4.go | 223 +++++++++++
catalog/rest/sigv4/sigv4_internal_test.go | 415 ++++++++++++++++++++
cmd/iceberg/main.go | 5 +
website/src/configuration.md | 25 +-
8 files changed, 1028 insertions(+), 546 deletions(-)
diff --git a/catalog/rest/options.go b/catalog/rest/options.go
index bf25b499d..884d0a759 100644
--- a/catalog/rest/options.go
+++ b/catalog/rest/options.go
@@ -23,7 +23,6 @@ import (
"net/url"
"github.com/apache/iceberg-go"
- "github.com/aws/aws-sdk-go-v2/aws"
)
type Option func(*options)
@@ -132,10 +131,35 @@ func WithPrefix(prefix string) Option {
}
}
-func WithAwsConfig(cfg aws.Config) Option {
+// WithSigner installs a fully constructed RequestSigner that signs every
+// catalog request in place (for example AWS SigV4). It is the escape hatch for
+// callers that build their own signer: it is used verbatim and therefore
+// bypasses the SigV4 machinery entirely, taking precedence over
+// WithSignerFactory, WithSigV4 / WithSigV4RegionSvc, the rest.sigv4-enabled
+// property, and any server-provided signing-region / signing-name overrides.
+// Use WithSignerFactory (or, for AWS, sigv4.WithAwsConfig) instead if you want
+// the region and service to stay driven by those settings.
+func WithSigner(signer RequestSigner) Option {
return func(o *options) {
- o.awsConfig = cfg
- o.awsConfigSet = true
+ o.signer = signer
+ }
+}
+
+// WithSignerFactory installs a factory that builds the RequestSigner from the
+// resolved signing configuration. Unlike WithSigner, which takes an
+// already-built signer, a factory lets WithSigV4 / WithSigV4RegionSvc and any
+// server-provided /v1/config overrides remain the single source of the signing
+// region and service: they are folded into the SignerConfig passed to the
+// factory. Optional backends such as
+// github.com/apache/iceberg-go/catalog/rest/sigv4 (via sigv4.WithAwsConfig)
use
+// this so the AWS SDK stays out of the core package.
+//
+// The factory is consulted only when SigV4 signing is enabled (WithSigV4,
+// WithSigV4RegionSvc, or the rest.sigv4-enabled property); on its own it does
+// not enable signing. An explicit WithSigner still takes precedence over it.
+func WithSignerFactory(factory SignerFactory) Option {
+ return func(o *options) {
+ o.signerFactory = factory
}
}
@@ -185,8 +209,8 @@ func WithTransportFactory(factory TransportFactory) Option {
}
type options struct {
- awsConfig aws.Config
- awsConfigSet bool
+ signer RequestSigner
+ signerFactory SignerFactory
tlsConfig *tls.Config
oauthToken string
credential string
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 5a6fd3039..29ca144c8 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -20,13 +20,10 @@ package rest
import (
"bytes"
"context"
- "crypto/sha256"
"crypto/tls"
- "encoding/hex"
"encoding/json"
"errors"
"fmt"
- "hash"
"io"
"iter"
"log/slog"
@@ -41,16 +38,11 @@ import (
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/catalog"
- internalaws "github.com/apache/iceberg-go/internal/awsconfig"
iceio "github.com/apache/iceberg-go/io"
"github.com/apache/iceberg-go/metrics"
"github.com/apache/iceberg-go/table"
"github.com/apache/iceberg-go/udf"
"github.com/apache/iceberg-go/view"
- "github.com/aws/aws-sdk-go-v2/aws"
- v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
- "github.com/aws/aws-sdk-go-v2/config"
- "github.com/aws/aws-sdk-go-v2/credentials"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
"golang.org/x/sync/semaphore"
@@ -94,12 +86,6 @@ const (
keyRestSigV4Region = "rest.signing-region"
keyRestSigV4Service = "rest.signing-name"
keyAuthUrl = "rest.authorization-url"
- // keyRestAccessKeyID and friends are the Java-client property names
for the
- // SigV4 signing credentials. They are accepted as aliases for the s3.*
- // properties; the s3.* keys take precedence when both are set.
- keyRestAccessKeyID = "rest.access-key-id"
- keyRestSecretAccessKey = "rest.secret-access-key"
- keyRestSessionToken = "rest.session-token"
// keyOAuth2ServerURI is the portable, spec-aligned property for the
OAuth2
// token endpoint used by Java, PyIceberg and iceberg-rust. It is the
// preferred key; keyAuthUrl is retained as a compatibility alias. When
both
@@ -247,13 +233,10 @@ type sessionTransport struct {
authManager AuthManager
defaultHeaders http.Header
- signer v4.HTTPSigner
- cfg aws.Config
- service string
- newHash func() hash.Hash
- // signingOrigin is the configured catalog origin. Requests to a
different
- // origin (e.g. a redirect hop) are not signed, so the SigV4
Authorization
- // header and session token never reach an unconfigured host.
+ signer RequestSigner
+ // signingOrigin is the configured catalog origin. A request to a
different
+ // origin (e.g. a redirect hop) is not signed, so the signer's
Authorization
+ // header and any session token never reach an unconfigured host.
signingOrigin *url.URL
}
@@ -278,9 +261,6 @@ func defaultedPort(u *url.URL) string {
}
}
-// from
https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/aws/signer/v4#Signer.SignHTTP
-const emptyStringHash =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
-
func (s *sessionTransport) RoundTrip(r *http.Request) (*http.Response, error) {
// A session default is applied unless the request already carries that
// header (a per-request override of any default, not just Content-Type
@@ -326,44 +306,7 @@ func (s *sessionTransport) RoundTrip(r *http.Request)
(*http.Response, error) {
}
if s.signer != nil && (s.signingOrigin == nil ||
sameOrigin(s.signingOrigin, r.URL)) {
- var payloadHash string
- if r.Body == nil {
- payloadHash = emptyStringHash
- } else {
- rdr, err := r.GetBody()
- if err != nil {
- return nil, err
- }
-
- h := s.newHash()
- if _, err = io.Copy(h, rdr); err != nil {
- if closeErr := rdr.Close(); closeErr != nil {
- err = errors.Join(err, closeErr)
- }
-
- return nil, err
- }
-
- if err = rdr.Close(); err != nil {
- return nil, err
- }
-
- payloadHash = hex.EncodeToString(h.Sum(nil))
- }
-
- creds, err := s.cfg.Credentials.Retrieve(r.Context())
- if err != nil {
- return nil, err
- }
-
- // Set the x-amz-content-sha256 header before signing.
- // This header is required for AWS SigV4 signature verification.
- r.Header.Set("x-amz-content-sha256", payloadHash)
-
- // modifies the request in place
- err = s.signer.SignHTTP(r.Context(), creds, r, payloadHash,
- s.service, s.cfg.Region, time.Now())
- if err != nil {
+ if err := s.signer.SignRequest(r); err != nil {
return nil, err
}
}
@@ -1137,67 +1080,25 @@ func (r *Catalog) createSession(ctx context.Context,
opts *options) (*http.Clien
session.authManager = authManager
}
- if opts.enableSigv4 {
- cfg := opts.awsConfig
- if !opts.awsConfigSet {
- creds, err := staticCredsFromProps(opts.additionalProps)
- if err != nil {
- cleanup()
-
- return nil, nil, err
- }
- // If no config provided, load defaults from
environment.
- cfg, err = config.LoadDefaultConfig(ctx)
- if err != nil {
- cleanup()
-
- return nil, nil, err
- }
- // Sign with the S3 credentials carried in the catalog
properties when
- // present, rather than only the AWS default credential
chain.
- if creds != nil {
- cfg.Credentials = creds
- }
- }
- if opts.sigv4Region != "" {
- cfg.Region = opts.sigv4Region
- }
+ signer, err := resolveSigner(ctx, opts)
+ if err != nil {
+ cleanup()
- session.cfg, session.service = cfg, opts.sigv4Service
- session.signer, session.newHash = v4.NewSigner(), sha256.New
+ return nil, nil, err
+ }
+ session.signer = signer
+ // A signer only signs requests to the configured catalog origin: a
request
+ // to a different origin (e.g. a redirect hop) is left unsigned, so the
+ // signer's Authorization header and any session token never reach an
+ // unconfigured host. The guard lives here, in core, so it covers every
+ // signer, including one installed verbatim via WithSigner.
+ if signer != nil {
session.signingOrigin = r.baseURI
}
return cl, cleanup, nil
}
-// staticCredsFromProps returns a static credentials provider built from the
-// signing-credential properties. It prefers the s3.* keys and falls back to
the
-// Java-compatible rest.* aliases, resolving the tuple atomically from a single
-// namespace so a partial pair is never completed with fields from the other
one.
-// It returns (nil, nil) when neither namespace sets any credential property,
so
-// the caller falls back to the default credential chain, and an
-// ErrIncompleteStaticCredentials error when the chosen namespace is
incomplete.
-func staticCredsFromProps(props iceberg.Properties) (aws.CredentialsProvider,
error) {
- namespaces := [][3]string{
- {iceio.S3AccessKeyID, iceio.S3SecretAccessKey,
iceio.S3SessionToken},
- {keyRestAccessKeyID, keyRestSecretAccessKey,
keyRestSessionToken},
- }
- for _, ns := range namespaces {
- accessKey, secretKey, token := props[ns[0]], props[ns[1]],
props[ns[2]]
- if accessKey == "" && secretKey == "" && token == "" {
- continue
- }
- if err := internalaws.ValidateStaticCredentials(ns[0], ns[1],
ns[2], accessKey, secretKey, token); err != nil {
- return nil, err
- }
-
- return credentials.NewStaticCredentialsProvider(accessKey,
secretKey, token), nil
- }
-
- return nil, nil
-}
-
func (r *Catalog) fetchConfig(ctx context.Context, opts *options) (*options,
error) {
params := url.Values{}
if opts.warehouseLocation != "" {
diff --git a/catalog/rest/rest_internal_test.go
b/catalog/rest/rest_internal_test.go
index 1018c1965..92da26362 100644
--- a/catalog/rest/rest_internal_test.go
+++ b/catalog/rest/rest_internal_test.go
@@ -23,10 +23,8 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
- "crypto/sha256"
"crypto/tls"
"crypto/x509"
- "encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -36,134 +34,36 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "sync"
"sync/atomic"
"testing"
"time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/catalog"
- internalaws "github.com/apache/iceberg-go/internal/awsconfig"
- iceio "github.com/apache/iceberg-go/io"
"github.com/apache/iceberg-go/table"
- "github.com/aws/aws-sdk-go-v2/aws"
- v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
- "github.com/aws/aws-sdk-go-v2/config"
- "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "golang.org/x/sync/errgroup"
)
-func TestStaticCredsFromProps(t *testing.T) {
- creds, err := staticCredsFromProps(iceberg.Properties{
- iceio.S3AccessKeyID: "AK",
- iceio.S3SecretAccessKey: "SK",
- iceio.S3SessionToken: "ST",
- })
- require.NoError(t, err)
- require.NotNil(t, creds)
- got, err := creds.Retrieve(context.Background())
- require.NoError(t, err)
- require.Equal(t, "AK", got.AccessKeyID)
- require.Equal(t, "SK", got.SecretAccessKey)
- require.Equal(t, "ST", got.SessionToken)
-
- creds, err = staticCredsFromProps(iceberg.Properties{})
- require.NoError(t, err, "no creds must fall back to the default chain")
- require.Nil(t, creds)
-
- _, err = staticCredsFromProps(iceberg.Properties{iceio.S3AccessKeyID:
"AK"})
- require.ErrorIs(t, err, internalaws.ErrIncompleteStaticCredentials, "a
lone access key must be an error, not the ambient identity")
+// markingSigner marks every request it signs with sentinel headers, so a test
+// can assert exactly which origins the session transport chose to sign without
+// pulling in a cloud SDK.
+type markingSigner struct{}
- _, err =
staticCredsFromProps(iceberg.Properties{iceio.S3SecretAccessKey: "SK"})
- require.ErrorIs(t, err, internalaws.ErrIncompleteStaticCredentials, "a
lone secret key must be an error, not the ambient identity")
+func (markingSigner) SignRequest(r *http.Request) error {
+ r.Header.Set("Authorization", "AWS4-HMAC-SHA256
Credential=SENTINELKEY/scope")
+ r.Header.Set("X-Amz-Security-Token", "SENTINELTOKEN")
- _, err = staticCredsFromProps(iceberg.Properties{iceio.S3SessionToken:
"ST"})
- require.ErrorIs(t, err, internalaws.ErrIncompleteStaticCredentials, "a
lone session token must be an error, not the ambient identity")
-
- creds, err = staticCredsFromProps(iceberg.Properties{
- keyRestAccessKeyID: "RAK",
- keyRestSecretAccessKey: "RSK",
- keyRestSessionToken: "RST",
- })
- require.NoError(t, err)
- require.NotNil(t, creds)
- got, err = creds.Retrieve(context.Background())
- require.NoError(t, err)
- require.Equal(t, "RAK", got.AccessKeyID)
- require.Equal(t, "RSK", got.SecretAccessKey)
- require.Equal(t, "RST", got.SessionToken)
-
- creds, err = staticCredsFromProps(iceberg.Properties{
- iceio.S3AccessKeyID: "AK",
- iceio.S3SecretAccessKey: "SK",
- keyRestAccessKeyID: "RAK",
- keyRestSecretAccessKey: "RSK",
- })
- require.NoError(t, err)
- got, err = creds.Retrieve(context.Background())
- require.NoError(t, err)
- require.Equal(t, "AK", got.AccessKeyID, "s3.* keys take precedence over
rest.* aliases")
- require.Equal(t, "SK", got.SecretAccessKey)
-
- _, err = staticCredsFromProps(iceberg.Properties{keyRestAccessKeyID:
"RAK"})
- require.ErrorIs(t, err, internalaws.ErrIncompleteStaticCredentials, "a
lone rest.* access key must be an error")
-
- _, err = staticCredsFromProps(iceberg.Properties{
- iceio.S3AccessKeyID: "AK",
- keyRestSecretAccessKey: "RSK",
- })
- require.ErrorIs(t, err, internalaws.ErrIncompleteStaticCredentials, "a
partial pair must not be completed with a field from the other namespace")
-
- creds, err = staticCredsFromProps(iceberg.Properties{
- iceio.S3AccessKeyID: "AK",
- iceio.S3SecretAccessKey: "SK",
- keyRestSessionToken: "RST",
- })
- require.NoError(t, err)
- got, err = creds.Retrieve(context.Background())
- require.NoError(t, err)
- require.Equal(t, "AK", got.AccessKeyID)
- require.Empty(t, got.SessionToken, "a complete s3.* pair must not
inherit an unrelated rest.* session token")
+ return nil
}
-// TestSigV4SignsWithPropsCredentials pins the wiring: the SigV4 Authorization
-// header must be signed with the credentials carried in the catalog
properties.
-func TestSigV4SignsWithPropsCredentials(t *testing.T) {
- var authHeader string
- mux := http.NewServeMux()
- mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r
*http.Request) {
- json.NewEncoder(w).Encode(map[string]any{"defaults":
map[string]any{}, "overrides": map[string]any{}})
- })
- mux.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
- authHeader = r.Header.Get("Authorization")
- w.WriteHeader(http.StatusOK)
- })
- srv := httptest.NewServer(mux)
- defer srv.Close()
-
- cat, err := NewCatalog(context.Background(), "rest", srv.URL,
- WithSigV4RegionSvc("us-east-1", "s3"),
- WithAdditionalProps(iceberg.Properties{
- iceio.S3AccessKeyID: "AKIDEXAMPLEPROPS",
- iceio.S3SecretAccessKey: "secretexample",
- }))
- require.NoError(t, err)
-
- req, err := http.NewRequestWithContext(context.Background(),
http.MethodGet, srv.URL+"/test", nil)
- require.NoError(t, err)
- resp, err := cat.cl.Do(req)
- require.NoError(t, err)
- require.NoError(t, resp.Body.Close())
-
- require.Contains(t, authHeader, "Credential=AKIDEXAMPLEPROPS/",
- "SigV4 must sign with the credentials from catalog properties,
not the default chain")
-}
-
-// TestSigv4DoesNotSignCrossOriginRedirect pins that a redirect to a different
-// origin is not re-signed, so the SigV4 Authorization header and session token
-// never reach an unconfigured host.
-func TestSigv4DoesNotSignCrossOriginRedirect(t *testing.T) {
+// TestSignerDoesNotSignCrossOriginRedirect pins the origin guard in
+// sessionTransport.RoundTrip: a redirect to a different origin is not signed,
so
+// the signer's Authorization header and any session token never reach an
+// unconfigured host. The guard is signer-agnostic, so it covers an explicit
+// WithSigner too; a marking signer exercises it without a real SigV4 backend.
+func TestSignerDoesNotSignCrossOriginRedirect(t *testing.T) {
var secondHit bool
var gotAuth, gotToken string
second := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
@@ -177,7 +77,7 @@ func TestSigv4DoesNotSignCrossOriginRedirect(t *testing.T) {
var firstAuth string
mux := http.NewServeMux()
mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r
*http.Request) {
- json.NewEncoder(w).Encode(map[string]any{"defaults":
map[string]any{}, "overrides": map[string]any{}})
+ _ = json.NewEncoder(w).Encode(map[string]any{"defaults":
map[string]any{}, "overrides": map[string]any{}})
})
mux.HandleFunc("/redirect", func(w http.ResponseWriter, r
*http.Request) {
firstAuth = r.Header.Get("Authorization")
@@ -186,14 +86,9 @@ func TestSigv4DoesNotSignCrossOriginRedirect(t *testing.T) {
first := httptest.NewServer(mux)
defer first.Close()
- cat, err := NewCatalog(context.Background(), "rest", first.URL,
- WithSigV4RegionSvc("us-east-1", "s3"),
- WithAdditionalProps(iceberg.Properties{
- iceio.S3AccessKeyID: "AKIDEXAMPLEPROPS",
- iceio.S3SecretAccessKey: "secretexample",
- iceio.S3SessionToken: "SESSIONTOKENEXAMPLE",
- }))
+ cat, err := NewCatalog(context.Background(), "rest", first.URL,
WithSigner(markingSigner{}))
require.NoError(t, err)
+ t.Cleanup(func() { _ = cat.Close() })
req, err := http.NewRequestWithContext(context.Background(),
http.MethodGet, first.URL+"/redirect", nil)
require.NoError(t, err)
@@ -201,9 +96,9 @@ func TestSigv4DoesNotSignCrossOriginRedirect(t *testing.T) {
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
- require.Contains(t, firstAuth, "Credential=AKIDEXAMPLEPROPS/", "the
configured origin must still be signed")
+ require.NotEmpty(t, firstAuth, "the configured origin must be signed")
require.True(t, secondHit, "the redirect target must be reached")
- require.Empty(t, gotAuth, "the redirect target must not receive the
SigV4 Authorization header")
+ require.Empty(t, gotAuth, "the redirect target must not receive the
Authorization header")
require.Empty(t, gotToken, "the redirect target must not receive the
session token")
}
@@ -934,134 +829,6 @@ func TestAuthUriHeader(t *testing.T) {
assert.Equal(t, "Bearer some_jwt_token", capturedAuthHeader)
}
-func TestSigv4EmptyStringHash(t *testing.T) {
- t.Parallel()
- hash := sha256.New()
- payloadHash := hex.EncodeToString(hash.Sum(nil))
- // Sanity check the constant.
- require.Equal(t, payloadHash, emptyStringHash)
-}
-
-func TestSigv4ContentSha256Header(t *testing.T) {
- t.Parallel()
-
- cfg, err := config.LoadDefaultConfig(context.Background(), func(opts
*config.LoadOptions) error {
- opts.Credentials = credentials.StaticCredentialsProvider{
- Value: aws.Credentials{
- AccessKeyID: "test-access-key",
- SecretAccessKey: "test-secret-key",
- },
- }
-
- return nil
- })
- require.NoError(t, err)
-
- t.Run("header set when sigv4 enabled", func(t *testing.T) {
- t.Parallel()
- var capturedHeader string
- mux := http.NewServeMux()
- srv := httptest.NewServer(mux)
- defer srv.Close()
-
- mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r
*http.Request) {
- json.NewEncoder(w).Encode(map[string]any{
- "defaults": map[string]any{}, "overrides":
map[string]any{},
- })
- })
-
- mux.HandleFunc("/test", func(w http.ResponseWriter, r
*http.Request) {
- capturedHeader = r.Header.Get("x-amz-content-sha256")
- w.WriteHeader(http.StatusOK)
- })
-
- cat, err := NewCatalog(context.Background(), "rest", srv.URL,
- WithSigV4(),
- WithSigV4RegionSvc("us-east-1", "s3"),
- WithAwsConfig(cfg))
- require.NoError(t, err)
-
- req, err := http.NewRequestWithContext(context.Background(),
http.MethodGet, srv.URL+"/test", nil)
- require.NoError(t, err)
-
- _, err = cat.cl.Do(req)
- require.NoError(t, err)
-
- assert.NotEmpty(t, capturedHeader, "x-amz-content-sha256 header
should be set when sigv4 is enabled")
- assert.Equal(t, emptyStringHash, capturedHeader, "header should
contain hash of empty body")
- })
-
- t.Run("header not set when sigv4 disabled", func(t *testing.T) {
- t.Parallel()
- var capturedHeader string
- headerPresent := false
- mux := http.NewServeMux()
- srv := httptest.NewServer(mux)
- defer srv.Close()
-
- mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r
*http.Request) {
- json.NewEncoder(w).Encode(map[string]any{
- "defaults": map[string]any{}, "overrides":
map[string]any{},
- })
- })
-
- mux.HandleFunc("/test", func(w http.ResponseWriter, r
*http.Request) {
- capturedHeader = r.Header.Get("x-amz-content-sha256")
- _, headerPresent = r.Header["X-Amz-Content-Sha256"]
- w.WriteHeader(http.StatusOK)
- })
-
- cat, err := NewCatalog(context.Background(), "rest", srv.URL)
- require.NoError(t, err)
-
- req, err := http.NewRequestWithContext(context.Background(),
http.MethodGet, srv.URL+"/test", nil)
- require.NoError(t, err)
-
- _, err = cat.cl.Do(req)
- require.NoError(t, err)
-
- assert.Empty(t, capturedHeader, "x-amz-content-sha256 header
should not be set when sigv4 is disabled")
- assert.False(t, headerPresent, "x-amz-content-sha256 header
should not be present when sigv4 is disabled")
- })
-
- t.Run("header contains correct hash for request body", func(t
*testing.T) {
- t.Parallel()
- var capturedHeader string
- mux := http.NewServeMux()
- srv := httptest.NewServer(mux)
- defer srv.Close()
-
- mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r
*http.Request) {
- json.NewEncoder(w).Encode(map[string]any{
- "defaults": map[string]any{}, "overrides":
map[string]any{},
- })
- })
-
- mux.HandleFunc("/test", func(w http.ResponseWriter, r
*http.Request) {
- capturedHeader = r.Header.Get("x-amz-content-sha256")
- w.WriteHeader(http.StatusOK)
- })
-
- cat, err := NewCatalog(context.Background(), "rest", srv.URL,
- WithSigV4(),
- WithSigV4RegionSvc("us-east-1", "s3"),
- WithAwsConfig(cfg))
- require.NoError(t, err)
-
- body := []byte(`{"test": "data"}`)
- expectedHash := sha256.Sum256(body)
- expectedHashStr := hex.EncodeToString(expectedHash[:])
-
- req, err := http.NewRequestWithContext(context.Background(),
http.MethodPost, srv.URL+"/test", bytes.NewReader(body))
- require.NoError(t, err)
-
- _, err = cat.cl.Do(req)
- require.NoError(t, err)
-
- assert.Equal(t, expectedHashStr, capturedHeader, "header should
contain correct hash of request body")
- })
-}
-
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
@@ -1173,176 +940,6 @@ func TestReqOptionsCompose(t *testing.T) {
assert.Equal(t, []string{"X-First", "X-Second", "X-Third"},
cfg.suppressHeaders)
}
-type closeTrackingReadCloser struct {
- *bytes.Reader
- closeErr error
- closed bool
-}
-
-func (r *closeTrackingReadCloser) Close() error {
- r.closed = true
-
- return r.closeErr
-}
-
-func newSigV4TestTransport(rt http.RoundTripper) *sessionTransport {
- return &sessionTransport{
- RoundTripper: rt,
- signer: v4.NewSigner(),
- cfg: aws.Config{
- Region: "us-east-1",
- Credentials: credentials.StaticCredentialsProvider{
- Value: aws.Credentials{
- AccessKeyID: "test-access-key",
- SecretAccessKey: "test-secret-key",
- },
- },
- },
- service: "s3",
- newHash: sha256.New,
- }
-}
-
-func TestSigv4ClosesClonedRequestBody(t *testing.T) {
- t.Parallel()
-
- body := []byte(`{"test": "data"}`)
- var clonedBody *closeTrackingReadCloser
-
- transport := newSigV4TestTransport(roundTripFunc(func(_ *http.Request)
(*http.Response, error) {
- return &http.Response{
- StatusCode: http.StatusOK,
- Body: io.NopCloser(bytes.NewReader(nil)),
- Header: make(http.Header),
- }, nil
- }))
-
- req, err := http.NewRequestWithContext(context.Background(),
http.MethodPost,
- "https://example.com/test", bytes.NewReader(body))
- require.NoError(t, err)
- req.GetBody = func() (io.ReadCloser, error) {
- clonedBody = &closeTrackingReadCloser{Reader:
bytes.NewReader(body)}
-
- return clonedBody, nil
- }
-
- resp, err := transport.RoundTrip(req)
- require.NoError(t, err)
- require.NoError(t, resp.Body.Close())
- require.NotNil(t, clonedBody)
- assert.True(t, clonedBody.closed)
-}
-
-func TestSigv4ReturnsClonedRequestBodyCloseError(t *testing.T) {
- t.Parallel()
-
- closeErr := errors.New("close failed")
- body := []byte(`{"test": "data"}`)
- var clonedBody *closeTrackingReadCloser
-
- transport := newSigV4TestTransport(roundTripFunc(func(_ *http.Request)
(*http.Response, error) {
- t.Fatal("request should not be sent when the signing body clone
fails to close")
-
- return nil, nil
- }))
-
- req, err := http.NewRequestWithContext(context.Background(),
http.MethodPost,
- "https://example.com/test", bytes.NewReader(body))
- require.NoError(t, err)
- req.GetBody = func() (io.ReadCloser, error) {
- clonedBody = &closeTrackingReadCloser{
- Reader: bytes.NewReader(body),
- closeErr: closeErr,
- }
-
- return clonedBody, nil
- }
-
- _, err = transport.RoundTrip(req)
- require.ErrorIs(t, err, closeErr)
- require.NotNil(t, clonedBody)
- assert.True(t, clonedBody.closed)
-}
-
-func TestSigv4ConcurrentSigners(t *testing.T) {
- t.Parallel()
- mux := http.NewServeMux()
- srv := httptest.NewUnstartedServer(mux)
- // If we use HTTP 1.1, this test can try to make too many connections
- // and exhaust ephemeral ports.
- srv.EnableHTTP2 = true
- srv.StartTLS() // Using TLS to easily support HTTP/2
- rootCAs := x509.NewCertPool()
- rootCAs.AddCert(srv.Certificate())
-
- mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r
*http.Request) {
- json.NewEncoder(w).Encode(map[string]any{
- "defaults": map[string]any{}, "overrides":
map[string]any{},
- })
- })
-
- cfg, err := config.LoadDefaultConfig(context.Background(), func(opts
*config.LoadOptions) error {
- opts.Credentials = credentials.StaticCredentialsProvider{
- Value: aws.Credentials{
- AccessKeyID: "abcdefghjklmnop",
- SecretAccessKey:
"01234567abcdefgh01234567abcdefgh01234567abcdefgh01234567abcdefgh",
- },
- }
-
- return nil
- })
- require.NoError(t, err)
-
- cat, err := NewCatalog(context.Background(), "rest", srv.URL,
- WithSigV4(),
- WithSigV4RegionSvc("abc", "def"),
- WithAwsConfig(cfg),
- WithTLSConfig(&tls.Config{
- RootCAs: rootCAs,
- }))
- require.NoError(t, err)
- assert.NotNil(t, cat)
-
- // We aren't recreating the signature logic to verify on the server.
We're
- // just running many concurrent requests to make sure the race detector
- // doesn't find any data races with how the session transport and signer
- // are used from concurrent goroutines.
- ctx, cancel := context.WithCancel(context.Background())
- grp, ctx := errgroup.WithContext(ctx)
- var count atomic.Uint64
- for range 10 {
- grp.Go(func() error {
- for {
- if err := ctx.Err(); err != nil {
- return nil
- }
- body := make([]byte, 1024)
- if _, err := rand.Read(body); err != nil {
- return err
- }
- // Intentionally using context.Background
instead of ctx so that we
- // don't get interrupted when context is
cancelled.
- req, err :=
http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL,
bytes.NewReader(body))
- if err != nil {
- return err
- }
- resp, err := cat.cl.Do(req)
- if err != nil {
- return err
- }
- // We don't actually care about the response,
only that it actually made it to the server.
- _, _ = io.Copy(io.Discard, resp.Body)
- _ = resp.Body.Close()
- count.Add(1)
- }
- })
- }
- time.Sleep(5 * time.Second)
- cancel()
- require.NoError(t, grp.Wait())
- t.Logf("issued %d requests", count.Load())
-}
-
func TestCredentialRefreshOnExpiry(t *testing.T) {
t.Parallel()
@@ -2224,3 +1821,164 @@ func TestNamespaceSeparatorDefaultsToUnitSeparator(t
*testing.T) {
require.NoError(t, err)
assert.Equal(t, "/v1/namespaces/a%1Fb", gotPath)
}
+
+// signerFunc adapts a function to the RequestSigner interface for tests.
+type signerFunc func(*http.Request) error
+
+func (f signerFunc) SignRequest(r *http.Request) error { return f(r) }
+
+func TestSessionTransportInvokesSigner(t *testing.T) {
+ t.Parallel()
+
+ var signed bool
+ var got http.Header
+ s := &sessionTransport{
+ RoundTripper: roundTripFunc(func(r *http.Request)
(*http.Response, error) {
+ got = r.Header.Clone()
+
+ return &http.Response{StatusCode: http.StatusOK, Body:
http.NoBody}, nil
+ }),
+ defaultHeaders: http.Header{},
+ signer: signerFunc(func(r *http.Request) error {
+ signed = true
+ r.Header.Set("X-Signed", "yes")
+
+ return nil
+ }),
+ }
+
+ req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
+ require.NoError(t, err)
+ _, err = s.RoundTrip(req)
+ require.NoError(t, err)
+ assert.True(t, signed, "signer.SignRequest should be invoked")
+ assert.Equal(t, "yes", got.Get("X-Signed"))
+}
+
+func TestSessionTransportSignerErrorAbortsRequest(t *testing.T) {
+ t.Parallel()
+
+ wantErr := errors.New("sign failed")
+ s := &sessionTransport{
+ RoundTripper: roundTripFunc(func(_ *http.Request)
(*http.Response, error) {
+ t.Fatal("request must not be sent when signing fails")
+
+ return nil, nil
+ }),
+ defaultHeaders: http.Header{},
+ signer: signerFunc(func(_ *http.Request) error { return
wantErr }),
+ }
+
+ req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
+ require.NoError(t, err)
+ _, err = s.RoundTrip(req)
+ require.ErrorIs(t, err, wantErr)
+}
+
+// TestSessionTransportConcurrentRoundTrip drives one shared sessionTransport
+// from many goroutines (each with its own request) to confirm the signing and
+// default-header path holds no per-request state that races. Meaningful under
+// the race detector; complements the sigv4 package's real-signer concurrency
+// test.
+func TestSessionTransportConcurrentRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ var count atomic.Int64
+ s := &sessionTransport{
+ RoundTripper: roundTripFunc(func(_ *http.Request)
(*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body:
http.NoBody}, nil
+ }),
+ defaultHeaders: http.Header{"X-Default": {"v"}},
+ signer: signerFunc(func(r *http.Request) error {
+ count.Add(1)
+ r.Header.Set("X-Signed", "yes")
+
+ return nil
+ }),
+ }
+
+ var wg sync.WaitGroup
+ for range 50 {
+ wg.Go(func() {
+ req, err := http.NewRequest(http.MethodGet,
"http://example.com", nil)
+ if err != nil {
+ t.Error(err)
+
+ return
+ }
+ if _, err := s.RoundTrip(req); err != nil {
+ t.Error(err)
+ }
+ })
+ }
+ wg.Wait()
+
+ assert.Equal(t, int64(50), count.Load())
+}
+
+// TestSigV4WithoutBackendReturnsHelpfulError verifies that requesting SigV4
+// without a registered signer backend fails with guidance naming the import to
+// add. This internal test binary cannot import catalog/rest/sigv4 (that would
be
+// an import cycle), so no init registers the "sigv4" backend and the registry
is
+// always empty here; the "no backend" path is therefore exercised without any
+// registry manipulation.
+func TestSigV4WithoutBackendReturnsHelpfulError(t *testing.T) {
+ t.Parallel()
+
+ mux := http.NewServeMux()
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ mux.HandleFunc("/v1/config", func(w http.ResponseWriter, _
*http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "defaults": map[string]any{}, "overrides":
map[string]any{},
+ })
+ })
+
+ _, err := NewCatalog(context.Background(), "rest", srv.URL, WithSigV4())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "catalog/rest/sigv4")
+}
+
+// TestSignerFactoryReceivesResolvedRegionService verifies that a
+// WithSignerFactory is handed the signing region and service resolved from
+// WithSigV4RegionSvc and any server-provided /v1/config overrides, rather than
+// values frozen when the option was constructed. This is the seam a pre-built
+// WithSigner (which sigv4.WithAwsConfig used before it became a factory) got
+// wrong: it ignored both WithSigV4RegionSvc and server overrides.
+func TestSignerFactoryReceivesResolvedRegionService(t *testing.T) {
+ t.Parallel()
+
+ mux := http.NewServeMux()
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ mux.HandleFunc("/v1/config", func(w http.ResponseWriter, _
*http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "defaults": map[string]any{},
+ "overrides": map[string]any{
+ keyRestSigV4Region: "ap-south-1",
+ keyRestSigV4Service: "s3tables",
+ },
+ })
+ })
+
+ // Sequential within NewCatalog: the factory is called for the bootstrap
+ // /v1/config request (pre-override) and again for the real session
+ // (post-override), so the last call carries the merged values.
+ var last SignerConfig
+ factory := func(_ context.Context, cfg SignerConfig) (RequestSigner,
error) {
+ last = cfg
+
+ return signerFunc(func(*http.Request) error { return nil }), nil
+ }
+
+ cat, err := NewCatalog(context.Background(), "rest", srv.URL,
+ WithSigV4RegionSvc("us-east-1", "execute-api"),
+ WithSignerFactory(factory))
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = cat.Close() })
+
+ assert.Equal(t, "ap-south-1", last.Region, "server signing-region
override must reach the factory")
+ assert.Equal(t, "s3tables", last.Service, "server signing-name override
must reach the factory")
+}
diff --git a/catalog/rest/signer.go b/catalog/rest/signer.go
new file mode 100644
index 000000000..0d330f396
--- /dev/null
+++ b/catalog/rest/signer.go
@@ -0,0 +1,133 @@
+// 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 rest
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "sync"
+
+ "github.com/apache/iceberg-go"
+)
+
+// SignerNameSigV4 is the scheme under which the AWS SigV4 backend registers
+// itself (see catalog/rest/sigv4). It is also the value carried by the
+// rest.sigv4-enabled property path. It is exported so the backend can register
+// under the exact same name the core looks up, rather than a duplicated string
+// literal that could silently drift.
+const SignerNameSigV4 = "sigv4"
+
+// RequestSigner signs an outgoing catalog HTTP request in place, for example
+// with AWS SigV4. Implementations live in optional sub-packages (see
+// github.com/apache/iceberg-go/catalog/rest/sigv4) so the core REST client
+// depends on no cloud SDK. Signing runs inside the session transport's
+// RoundTrip, after auth headers are applied, and may use req.Context.
+type RequestSigner interface {
+ SignRequest(req *http.Request) error
+}
+
+// SignerConfig carries the signing parameters the REST core knows about,
+// deliberately free of any cloud-SDK types. A SignerFactory turns it into a
+// concrete RequestSigner.
+type SignerConfig struct {
+ // Region is the signing region (SigV4 signing-region).
+ Region string
+ // Service is the signing service name (SigV4 signing-name), e.g.
+ // "execute-api", "s3tables".
+ Service string
+ // Props carries the catalog properties after /v1/config overrides have
been
+ // folded in. A backend may read signing credentials from it (e.g. the
sigv4
+ // backend builds a static credentials provider from the s3.* / rest.*
keys)
+ // without the core knowing any cloud-SDK property names. It may be nil
or
+ // empty when no properties were supplied.
+ Props iceberg.Properties
+}
+
+// SignerFactory builds a RequestSigner from core configuration. A signing
+// backend registers one under a scheme name via RegisterSigner, so that a
+// blank import of the backend package is enough to enable property- or
+// option-driven signing (e.g. WithSigV4 / rest.sigv4-enabled).
+type SignerFactory func(ctx context.Context, cfg SignerConfig) (RequestSigner,
error)
+
+var (
+ signerMu sync.RWMutex
+ signerRegistry = map[string]SignerFactory{}
+)
+
+// RegisterSigner registers a signer factory under name (e.g. "sigv4"). It is
+// intended to be called from a backend package's init function. Registering
+// the same name twice replaces the previous factory. It panics if factory is
+// nil, since a nil factory is a programming error that would otherwise surface
+// only later, as a failure when signing is resolved.
+func RegisterSigner(name string, factory SignerFactory) {
+ if factory == nil {
+ panic(fmt.Sprintf("rest: RegisterSigner: nil factory for %q",
name))
+ }
+
+ signerMu.Lock()
+ defer signerMu.Unlock()
+ signerRegistry[name] = factory
+}
+
+func lookupSigner(name string) (SignerFactory, bool) {
+ signerMu.RLock()
+ defer signerMu.RUnlock()
+ f, ok := signerRegistry[name]
+
+ return f, ok
+}
+
+// resolveSigner determines the request signer for a session. It returns
+// (nil, nil) when signing is not configured, and a helpful error when SigV4 is
+// requested but no backend has been imported.
+//
+// Precedence:
+// - An explicit WithSigner is used verbatim (the caller fully built it), so
it
+// bypasses the sigv4-enabled / signing-region / signing-name settings and
any
+// server-provided overrides.
+// - Otherwise, when SigV4 is enabled (WithSigV4 / WithSigV4RegionSvc or the
+// rest.sigv4-enabled property), a WithSignerFactory (e.g.
sigv4.WithAwsConfig)
+// builds the signer if one was supplied, else the registered "sigv4"
backend
+// does. Both receive the resolved region/service, which by this point
already
+// include any /v1/config overrides fetchConfig folded into opts.
+func resolveSigner(ctx context.Context, opts *options) (RequestSigner, error) {
+ if opts.signer != nil {
+ return opts.signer, nil
+ }
+
+ if !opts.enableSigv4 {
+ return nil, nil
+ }
+
+ factory := opts.signerFactory
+ if factory == nil {
+ var ok bool
+ if factory, ok = lookupSigner(SignerNameSigV4); !ok {
+ return nil, fmt.Errorf(
+ "rest: SigV4 signing was requested (%s) but no
signer backend is registered; add a blank import: import _ %q",
+ keyRestSigV4,
"github.com/apache/iceberg-go/catalog/rest/sigv4")
+ }
+ }
+
+ return factory(ctx, SignerConfig{
+ Region: opts.sigv4Region,
+ Service: opts.sigv4Service,
+ Props: opts.additionalProps,
+ })
+}
diff --git a/catalog/rest/sigv4/sigv4.go b/catalog/rest/sigv4/sigv4.go
new file mode 100644
index 000000000..9e9b42e24
--- /dev/null
+++ b/catalog/rest/sigv4/sigv4.go
@@ -0,0 +1,223 @@
+// 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 sigv4 provides AWS Signature Version 4 signing for the REST catalog
+// client. It is an optional backend: importing it (even as a blank import)
+// registers the "sigv4" signer so that WithSigV4 / the rest.sigv4-enabled
+// property work, and it keeps the AWS SDK out of the core catalog/rest package
+// for consumers that do not need it.
+//
+// import (
+// "github.com/apache/iceberg-go/catalog/rest"
+// _ "github.com/apache/iceberg-go/catalog/rest/sigv4"
+// )
+//
+// cat, err := rest.NewCatalog(ctx, "c", uri,
rest.WithSigV4RegionSvc("us-east-1", "s3tables"))
+//
+// To sign with an explicit aws.Config, pass sigv4.WithAwsConfig alongside
+// WithSigV4 / WithSigV4RegionSvc (no blank import needed); the option supplies
+// the config while those options still drive the signing region and service.
+package sigv4
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/catalog/rest"
+ internalaws "github.com/apache/iceberg-go/internal/awsconfig"
+ iceio "github.com/apache/iceberg-go/io"
+ "github.com/aws/aws-sdk-go-v2/aws"
+ v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+)
+
+// emptyStringHash is the SHA-256 of the empty string, used as the payload hash
+// for requests without a body.
+// from
https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/aws/signer/v4#Signer.SignHTTP
+const emptyStringHash =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+
+// defaultSigningService is the SigV4 service name used when none is supplied,
+// matching the Iceberg Java reference default
(AwsProperties.REST_SIGNING_NAME_DEFAULT).
+const defaultSigningService = "execute-api"
+
+// keyRestAccessKeyID and friends are the Java-client property names for the
+// SigV4 signing credentials. They are accepted as aliases for the s3.*
+// properties; the s3.* keys take precedence when both are set.
+const (
+ keyRestAccessKeyID = "rest.access-key-id"
+ keyRestSecretAccessKey = "rest.secret-access-key"
+ keyRestSessionToken = "rest.session-token"
+)
+
+func init() {
+ rest.RegisterSigner(rest.SignerNameSigV4, func(ctx context.Context, cfg
rest.SignerConfig) (rest.RequestSigner, error) {
+ creds, err := staticCredsFromProps(cfg.Props)
+ if err != nil {
+ return nil, err
+ }
+
+ awscfg, err := config.LoadDefaultConfig(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("sigv4: load AWS config: %w",
err)
+ }
+ // Sign with the credentials carried in the catalog properties
when present,
+ // rather than only the AWS default credential chain.
+ if creds != nil {
+ awscfg.Credentials = creds
+ }
+
+ return buildSigner(awscfg, cfg), nil
+ })
+}
+
+// staticCredsFromProps returns a static credentials provider built from the
+// signing-credential properties carried in the catalog config. It prefers the
+// s3.* keys and falls back to the Java-compatible rest.* aliases, resolving
the
+// tuple atomically from a single namespace so a partial pair is never
completed
+// with fields from the other one. It returns (nil, nil) when neither namespace
+// sets any credential property, so the caller falls back to the default
+// credential chain, and an ErrIncompleteStaticCredentials error when the
chosen
+// namespace is incomplete.
+//
+// An explicit aws.Config supplied via WithAwsConfig bypasses this: that path
+// already fully specifies the credentials, so it never consults the
properties.
+func staticCredsFromProps(props iceberg.Properties) (aws.CredentialsProvider,
error) {
+ namespaces := [][3]string{
+ {iceio.S3AccessKeyID, iceio.S3SecretAccessKey,
iceio.S3SessionToken},
+ {keyRestAccessKeyID, keyRestSecretAccessKey,
keyRestSessionToken},
+ }
+ for _, ns := range namespaces {
+ accessKey, secretKey, token := props[ns[0]], props[ns[1]],
props[ns[2]]
+ if accessKey == "" && secretKey == "" && token == "" {
+ continue
+ }
+ if err := internalaws.ValidateStaticCredentials(ns[0], ns[1],
ns[2], accessKey, secretKey, token); err != nil {
+ return nil, err
+ }
+
+ return credentials.NewStaticCredentialsProvider(accessKey,
secretKey, token), nil
+ }
+
+ return nil, nil
+}
+
+// buildSigner applies the REST-core signing config to an aws.Config and
returns
+// a ready signer. A non-empty cfg.Region overrides awscfg.Region; an empty
+// service falls back to defaultSigningService. The latter covers the
+// property-only path (rest.sigv4-enabled without rest.signing-name), matching
+// WithSigV4, so signatures stay valid.
+func buildSigner(awscfg aws.Config, cfg rest.SignerConfig) *signer {
+ if cfg.Region != "" {
+ awscfg.Region = cfg.Region
+ }
+
+ service := cfg.Service
+ if service == "" {
+ service = defaultSigningService
+ }
+
+ return newSigner(awscfg, service)
+}
+
+// signer signs HTTP requests with AWS Signature Version 4. It implements
+// rest.RequestSigner.
+type signer struct {
+ httpSigner v4.HTTPSigner
+ cfg aws.Config
+ service string
+}
+
+func newSigner(cfg aws.Config, service string) *signer {
+ return &signer{
+ httpSigner: v4.NewSigner(),
+ cfg: cfg,
+ service: service,
+ }
+}
+
+// SignRequest signs r in place with SigV4: it computes the payload hash, sets
+// the required x-amz-content-sha256 header, and applies the signature.
+func (s *signer) SignRequest(r *http.Request) error {
+ var payloadHash string
+ if r.Body == nil {
+ payloadHash = emptyStringHash
+ } else {
+ // SigV4 needs the payload hash, so the body must be
re-readable.
+ // http.NewRequest* sets GetBody for the standard body types; a
+ // hand-built request with a raw io.ReadCloser body and no
GetBody cannot
+ // be signed, so fail with a clear error instead of a nil-func
panic.
+ if r.GetBody == nil {
+ return errors.New("sigv4: cannot sign request whose
body is not re-readable (Request.GetBody is nil)")
+ }
+
+ rdr, err := r.GetBody()
+ if err != nil {
+ return err
+ }
+
+ h := sha256.New()
+ if _, err = io.Copy(h, rdr); err != nil {
+ if closeErr := rdr.Close(); closeErr != nil {
+ err = errors.Join(err, closeErr)
+ }
+
+ return err
+ }
+
+ if err = rdr.Close(); err != nil {
+ return err
+ }
+
+ payloadHash = hex.EncodeToString(h.Sum(nil))
+ }
+
+ creds, err := s.cfg.Credentials.Retrieve(r.Context())
+ if err != nil {
+ return err
+ }
+
+ // Set the x-amz-content-sha256 header before signing; SigV4 signature
+ // verification requires it.
+ r.Header.Set("x-amz-content-sha256", payloadHash)
+
+ return s.httpSigner.SignHTTP(r.Context(), creds, r, payloadHash,
s.service, s.cfg.Region, time.Now())
+}
+
+// WithAwsConfig returns a rest.Option that signs catalog requests with AWS
+// SigV4 using the supplied aws.Config, without consulting the AWS default
+// credential chain. It replaces the former rest.WithAwsConfig, whose AWS SDK
+// dependency now lives only in this optional sub-package.
+//
+// Like the blank-import path, it does not by itself enable signing: pair it
+// with WithSigV4 or WithSigV4RegionSvc (or the rest.sigv4-enabled property),
+// which remain the single source of the signing region and service. Those
+// values, plus any server-provided /v1/config overrides, are applied on top of
+// cfg when the signer is built (a non-empty region overrides cfg.Region; an
+// empty service defaults to "execute-api").
+func WithAwsConfig(cfg aws.Config) rest.Option {
+ return rest.WithSignerFactory(func(_ context.Context, sc
rest.SignerConfig) (rest.RequestSigner, error) {
+ return buildSigner(cfg, sc), nil
+ })
+}
diff --git a/catalog/rest/sigv4/sigv4_internal_test.go
b/catalog/rest/sigv4/sigv4_internal_test.go
new file mode 100644
index 000000000..94c14b61d
--- /dev/null
+++ b/catalog/rest/sigv4/sigv4_internal_test.go
@@ -0,0 +1,415 @@
+// 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 sigv4
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/catalog/rest"
+ internalaws "github.com/apache/iceberg-go/internal/awsconfig"
+ iceio "github.com/apache/iceberg-go/io"
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestSigner(t *testing.T) *signer {
+ t.Helper()
+
+ cfg, err := config.LoadDefaultConfig(context.Background(), func(o
*config.LoadOptions) error {
+ o.Credentials = credentials.StaticCredentialsProvider{
+ Value: aws.Credentials{
+ AccessKeyID: "test-access-key",
+ SecretAccessKey: "test-secret-key",
+ },
+ }
+
+ return nil
+ })
+ require.NoError(t, err)
+ cfg.Region = "us-east-1"
+
+ return newSigner(cfg, "s3")
+}
+
+func TestEmptyStringHash(t *testing.T) {
+ t.Parallel()
+
+ h := sha256.New()
+ assert.Equal(t, hex.EncodeToString(h.Sum(nil)), emptyStringHash)
+}
+
+func TestSignRequestEmptyBodyContentHash(t *testing.T) {
+ t.Parallel()
+
+ s := newTestSigner(t)
+ req, err := http.NewRequestWithContext(context.Background(),
http.MethodGet, "https://example.com/test", nil)
+ require.NoError(t, err)
+ require.NoError(t, s.SignRequest(req))
+
+ assert.Equal(t, emptyStringHash, req.Header.Get("x-amz-content-sha256"))
+ assert.NotEmpty(t, req.Header.Get("Authorization"), "SigV4 should set
the Authorization header")
+}
+
+func TestSignRequestBodyContentHash(t *testing.T) {
+ t.Parallel()
+
+ s := newTestSigner(t)
+ body := []byte(`{"test": "data"}`)
+ sum := sha256.Sum256(body)
+
+ req, err := http.NewRequestWithContext(context.Background(),
http.MethodPost, "https://example.com/test", bytes.NewReader(body))
+ require.NoError(t, err)
+ require.NoError(t, s.SignRequest(req))
+
+ assert.Equal(t, hex.EncodeToString(sum[:]),
req.Header.Get("x-amz-content-sha256"))
+}
+
+func TestSignRequestNilGetBodyReturnsError(t *testing.T) {
+ t.Parallel()
+
+ s := newTestSigner(t)
+ req, err := http.NewRequestWithContext(context.Background(),
http.MethodPost, "https://example.com/test", bytes.NewReader([]byte(`{}`)))
+ require.NoError(t, err)
+ req.GetBody = nil // a hand-built request whose body cannot be re-read
+
+ err = s.SignRequest(req)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "GetBody", "error should explain the
body is not re-readable")
+}
+
+type closeTrackingReadCloser struct {
+ *bytes.Reader
+ closeErr error
+ closed bool
+}
+
+func (r *closeTrackingReadCloser) Close() error {
+ r.closed = true
+
+ return r.closeErr
+}
+
+func TestSignRequestClosesClonedBody(t *testing.T) {
+ t.Parallel()
+
+ s := newTestSigner(t)
+ body := []byte(`{"test": "data"}`)
+ var cloned *closeTrackingReadCloser
+
+ req, err := http.NewRequestWithContext(context.Background(),
http.MethodPost, "https://example.com/test", bytes.NewReader(body))
+ require.NoError(t, err)
+ req.GetBody = func() (io.ReadCloser, error) {
+ cloned = &closeTrackingReadCloser{Reader: bytes.NewReader(body)}
+
+ return cloned, nil
+ }
+
+ require.NoError(t, s.SignRequest(req))
+ require.NotNil(t, cloned)
+ assert.True(t, cloned.closed)
+}
+
+func TestSignRequestReturnsClonedBodyCloseError(t *testing.T) {
+ t.Parallel()
+
+ s := newTestSigner(t)
+ closeErr := errors.New("close failed")
+ body := []byte(`{"test": "data"}`)
+ var cloned *closeTrackingReadCloser
+
+ req, err := http.NewRequestWithContext(context.Background(),
http.MethodPost, "https://example.com/test", bytes.NewReader(body))
+ require.NoError(t, err)
+ req.GetBody = func() (io.ReadCloser, error) {
+ cloned = &closeTrackingReadCloser{Reader:
bytes.NewReader(body), closeErr: closeErr}
+
+ return cloned, nil
+ }
+
+ err = s.SignRequest(req)
+ require.ErrorIs(t, err, closeErr)
+ require.NotNil(t, cloned)
+ assert.True(t, cloned.closed)
+}
+
+func TestSignRequestConcurrent(t *testing.T) {
+ t.Parallel()
+
+ // POSTs with a body so the payload-hashing path (GetBody clone +
SHA-256)
+ // runs concurrently on a single shared signer, exercising the shared v4
+ // signer and aws.Config under the race detector.
+ s := newTestSigner(t)
+ body := []byte(`{"test":"data"}`)
+ var wg sync.WaitGroup
+ for range 20 {
+ wg.Go(func() {
+ req, err :=
http.NewRequestWithContext(context.Background(), http.MethodPost,
"https://example.com/test", bytes.NewReader(body))
+ if err == nil {
+ err = s.SignRequest(req)
+ }
+ if err != nil {
+ t.Error(err)
+ }
+ })
+ }
+ wg.Wait()
+}
+
+// TestRegisteredBackendEnablesSigV4 verifies that importing this package (its
+// init registers the sigv4 signer) lets rest.WithSigV4RegionSvc resolve
without
+// the caller supplying an explicit signer, and that the resolved signer
+// actually signs outbound catalog requests end to end.
+func TestRegisteredBackendEnablesSigV4(t *testing.T) {
+ // t.Setenv precludes t.Parallel; static credentials let the registered
+ // factory's config.LoadDefaultConfig resolve offline (no EC2 IMDS
lookup).
+ t.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
+ t.Setenv("AWS_REGION", "us-east-1")
+
+ var gotAuth, gotSHA string
+ mux := http.NewServeMux()
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r
*http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ gotSHA = r.Header.Get("x-amz-content-sha256")
+ json.NewEncoder(w).Encode(map[string]any{
+ "defaults": map[string]any{}, "overrides":
map[string]any{},
+ })
+ })
+
+ cat, err := rest.NewCatalog(context.Background(), "rest", srv.URL,
+ rest.WithSigV4RegionSvc("us-east-1", "s3"))
+ require.NoError(t, err)
+ require.NotNil(t, cat)
+ t.Cleanup(func() { _ = cat.Close() })
+
+ // The registered backend must actually sign the bootstrap /v1/config
request,
+ // not merely let catalog construction succeed.
+ assert.Contains(t, gotAuth, "AWS4-HMAC-SHA256", "request should carry a
SigV4 Authorization header")
+ assert.NotEmpty(t, gotSHA, "request should carry x-amz-content-sha256")
+}
+
+// TestWithAwsConfigUsesConfiguredRegionService verifies that
sigv4.WithAwsConfig
+// signs with the region/service from WithSigV4RegionSvc, not the aws.Config's
+// own region. Before WithAwsConfig became a signer factory it froze the scope
at
+// option-construction time, so the natural migration (keep WithSigV4RegionSvc,
+// swap rest.WithAwsConfig for sigv4.WithAwsConfig) silently signed for the
wrong
+// scope and the server rejected it with a 403.
+func TestWithAwsConfigUsesConfiguredRegionService(t *testing.T) {
+ t.Parallel()
+
+ var gotAuth string
+ mux := http.NewServeMux()
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r
*http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ json.NewEncoder(w).Encode(map[string]any{
+ "defaults": map[string]any{}, "overrides":
map[string]any{},
+ })
+ })
+
+ cfg := aws.Config{
+ Region: "eu-central-1", // must be overridden by
WithSigV4RegionSvc below
+ Credentials: credentials.StaticCredentialsProvider{Value:
aws.Credentials{
+ AccessKeyID: "test-access-key",
+ SecretAccessKey: "test-secret-key",
+ }},
+ }
+
+ cat, err := rest.NewCatalog(context.Background(), "rest", srv.URL,
+ rest.WithSigV4RegionSvc("us-west-2", "s3tables"),
+ WithAwsConfig(cfg))
+ require.NoError(t, err)
+ require.NotNil(t, cat)
+ t.Cleanup(func() { _ = cat.Close() })
+
+ assert.Contains(t, gotAuth, "/us-west-2/s3tables/aws4_request",
+ "signature scope must come from WithSigV4RegionSvc, not
aws.Config.Region")
+ assert.NotContains(t, gotAuth, "eu-central-1",
+ "aws.Config.Region must not leak into the signature scope")
+}
+
+// TestConcurrentSignedCatalogRequests drives the full transport+signer stack
+// from concurrent goroutines so the race detector can observe the shared
signer
+// registry lookups and the signing path. Every request the server sees must be
+// SigV4-signed. This restores the end-to-end concurrency coverage the previous
+// in-package TestSigv4ConcurrentSigners provided before signing moved here.
+func TestConcurrentSignedCatalogRequests(t *testing.T) {
+ t.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
+ t.Setenv("AWS_REGION", "us-east-1")
+
+ var total, signed atomic.Int64
+ mux := http.NewServeMux()
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r
*http.Request) {
+ total.Add(1)
+ if strings.Contains(r.Header.Get("Authorization"),
"AWS4-HMAC-SHA256") &&
+ r.Header.Get("x-amz-content-sha256") != "" {
+ signed.Add(1)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "defaults": map[string]any{}, "overrides":
map[string]any{},
+ })
+ })
+
+ var wg sync.WaitGroup
+ for range 10 {
+ wg.Go(func() {
+ cat, err := rest.NewCatalog(context.Background(),
"rest", srv.URL,
+ rest.WithSigV4RegionSvc("us-east-1", "s3"))
+ if err != nil {
+ t.Error(err)
+
+ return
+ }
+ _ = cat.Close()
+ })
+ }
+ wg.Wait()
+
+ require.Positive(t, total.Load())
+ assert.Equal(t, total.Load(), signed.Load(), "every request must be
SigV4-signed")
+}
+
+func TestStaticCredsFromProps(t *testing.T) {
+ t.Parallel()
+
+ creds, err := staticCredsFromProps(iceberg.Properties{
+ iceio.S3AccessKeyID: "AK",
+ iceio.S3SecretAccessKey: "SK",
+ iceio.S3SessionToken: "ST",
+ })
+ require.NoError(t, err)
+ require.NotNil(t, creds)
+ got, err := creds.Retrieve(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, "AK", got.AccessKeyID)
+ require.Equal(t, "SK", got.SecretAccessKey)
+ require.Equal(t, "ST", got.SessionToken)
+
+ creds, err = staticCredsFromProps(iceberg.Properties{})
+ require.NoError(t, err, "no creds must fall back to the default chain")
+ require.Nil(t, creds)
+
+ _, err = staticCredsFromProps(iceberg.Properties{iceio.S3AccessKeyID:
"AK"})
+ require.ErrorIs(t, err, internalaws.ErrIncompleteStaticCredentials, "a
lone access key must be an error, not the ambient identity")
+
+ _, err =
staticCredsFromProps(iceberg.Properties{iceio.S3SecretAccessKey: "SK"})
+ require.ErrorIs(t, err, internalaws.ErrIncompleteStaticCredentials, "a
lone secret key must be an error, not the ambient identity")
+
+ _, err = staticCredsFromProps(iceberg.Properties{iceio.S3SessionToken:
"ST"})
+ require.ErrorIs(t, err, internalaws.ErrIncompleteStaticCredentials, "a
lone session token must be an error, not the ambient identity")
+
+ creds, err = staticCredsFromProps(iceberg.Properties{
+ keyRestAccessKeyID: "RAK",
+ keyRestSecretAccessKey: "RSK",
+ keyRestSessionToken: "RST",
+ })
+ require.NoError(t, err)
+ require.NotNil(t, creds)
+ got, err = creds.Retrieve(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, "RAK", got.AccessKeyID)
+ require.Equal(t, "RSK", got.SecretAccessKey)
+ require.Equal(t, "RST", got.SessionToken)
+
+ creds, err = staticCredsFromProps(iceberg.Properties{
+ iceio.S3AccessKeyID: "AK",
+ iceio.S3SecretAccessKey: "SK",
+ keyRestAccessKeyID: "RAK",
+ keyRestSecretAccessKey: "RSK",
+ })
+ require.NoError(t, err)
+ got, err = creds.Retrieve(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, "AK", got.AccessKeyID, "s3.* keys take precedence over
rest.* aliases")
+ require.Equal(t, "SK", got.SecretAccessKey)
+
+ _, err = staticCredsFromProps(iceberg.Properties{keyRestAccessKeyID:
"RAK"})
+ require.ErrorIs(t, err, internalaws.ErrIncompleteStaticCredentials, "a
lone rest.* access key must be an error")
+
+ _, err = staticCredsFromProps(iceberg.Properties{
+ iceio.S3AccessKeyID: "AK",
+ keyRestSecretAccessKey: "RSK",
+ })
+ require.ErrorIs(t, err, internalaws.ErrIncompleteStaticCredentials, "a
partial pair must not be completed with a field from the other namespace")
+
+ creds, err = staticCredsFromProps(iceberg.Properties{
+ iceio.S3AccessKeyID: "AK",
+ iceio.S3SecretAccessKey: "SK",
+ keyRestSessionToken: "RST",
+ })
+ require.NoError(t, err)
+ got, err = creds.Retrieve(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, "AK", got.AccessKeyID)
+ require.Empty(t, got.SessionToken, "a complete s3.* pair must not
inherit an unrelated rest.* session token")
+}
+
+// TestSigV4SignsWithPropsCredentials pins the wiring end to end: the SigV4
+// Authorization header on the bootstrap request must be signed with the
+// credentials carried in the catalog properties (via SignerConfig.Props), not
+// the AWS default chain.
+func TestSigV4SignsWithPropsCredentials(t *testing.T) {
+ t.Parallel()
+
+ var gotAuth string
+ mux := http.NewServeMux()
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r
*http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ _ = json.NewEncoder(w).Encode(map[string]any{"defaults":
map[string]any{}, "overrides": map[string]any{}})
+ })
+
+ cat, err := rest.NewCatalog(context.Background(), "rest", srv.URL,
+ rest.WithSigV4RegionSvc("us-east-1", "s3"),
+ rest.WithAdditionalProps(iceberg.Properties{
+ iceio.S3AccessKeyID: "AKIDEXAMPLEPROPS",
+ iceio.S3SecretAccessKey: "secretexample",
+ }))
+ require.NoError(t, err)
+ require.NotNil(t, cat)
+ t.Cleanup(func() { _ = cat.Close() })
+
+ require.Contains(t, gotAuth, "Credential=AKIDEXAMPLEPROPS/",
+ "SigV4 must sign with the credentials from catalog properties,
not the default chain")
+}
diff --git a/cmd/iceberg/main.go b/cmd/iceberg/main.go
index 3969e111e..760dfe934 100644
--- a/cmd/iceberg/main.go
+++ b/cmd/iceberg/main.go
@@ -36,6 +36,11 @@ import (
"github.com/apache/iceberg-go/catalog/hadoop"
"github.com/apache/iceberg-go/catalog/hive"
"github.com/apache/iceberg-go/catalog/rest"
+
+ // Register the AWS SigV4 signer backend so the --sigv4 /
signing-region /
+ // signing-name flags work. catalog/rest itself no longer links the AWS
SDK;
+ // the CLI already pulls it in via catalog/glue, so this adds no new
weight.
+ _ "github.com/apache/iceberg-go/catalog/rest/sigv4"
sqlcat "github.com/apache/iceberg-go/catalog/sql"
"github.com/apache/iceberg-go/config"
_ "github.com/apache/iceberg-go/io/gocloud"
diff --git a/website/src/configuration.md b/website/src/configuration.md
index 76807f304..0faebce78 100644
--- a/website/src/configuration.md
+++ b/website/src/configuration.md
@@ -71,11 +71,34 @@ The most option-rich surface. Source:
[`catalog/rest/options.go`](https://github
| Group | Options |
|---|---|
| Authentication | `WithCredential`, `WithOAuthToken`, `WithAuthManager`,
`WithAuthURI`, `WithScope`, `WithAudience`, `WithResource` |
-| AWS SigV4 | `WithSigV4`, `WithSigV4RegionSvc`, `WithAwsConfig` |
+| AWS SigV4 | `WithSigV4`, `WithSigV4RegionSvc`, `WithSigner` |
| HTTP | `WithHeaders`, `WithTLSConfig`, `WithOAuthTLSConfig`,
`WithCustomTransport` |
| Catalog routing | `WithPrefix`, `WithWarehouseLocation`,
`WithMetadataLocation` |
| Pass-through | `WithAdditionalProps` |
+> **AWS SigV4 is an optional backend.** `catalog/rest` no longer links the AWS
+> SDK. To sign REST requests with SigV4, pull in the
+>
[`catalog/rest/sigv4`](https://github.com/apache/iceberg-go/blob/main/catalog/rest/sigv4/sigv4.go)
+> sub-package one of two ways:
+>
+> - Property or ambient credentials: add a blank import
+> `_ "github.com/apache/iceberg-go/catalog/rest/sigv4"` and enable signing
with
+> `WithSigV4` / `WithSigV4RegionSvc` or the `rest.sigv4-enabled` property.
The
+> backend signs with the `s3.*` credential properties when set (falling back
to
+> the `rest.*` aliases), otherwise with the AWS default credential chain.
+> - Explicit config: pass `sigv4.WithAwsConfig(cfg)` to `NewCatalog` alongside
+> `WithSigV4` / `WithSigV4RegionSvc` (no blank import needed). It supplies
the
+> `aws.Config`; the signing service comes from those options (or a server
+> `/v1/config` override), and so does the region when one is set. Otherwise
+> the region falls back to `cfg.Region`.
+>
+> The former `rest.WithAwsConfig(aws.Config)` has been removed in favor of
these
+> paths. Because signing is enabled by `WithSigV4` / `WithSigV4RegionSvc` (or
the
+> `rest.sigv4-enabled` property) rather than by supplying a config, a program
+> that loads a REST catalog with `rest.sigv4-enabled=true` now fails at startup
+> until the `catalog/rest/sigv4` backend is blank-imported; the error names the
+> import to add.
+
#### Metrics reporting
The REST catalog can POST scan and commit metrics to the catalog's