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 f7e2667e3 fix(catalog/rest): sign SigV4 with credentials from catalog
properties (#1999)
f7e2667e3 is described below
commit f7e2667e318f61f249dadd82657b540cce13f9f2
Author: İrem Çağın Yurttürk <[email protected]>
AuthorDate: Thu Oct 1 21:43:14 2026 +0300
fix(catalog/rest): sign SigV4 with credentials from catalog properties
(#1999)
* fix(catalog/rest): sign SigV4 with credentials from catalog properties
The REST catalog's SigV4 signer only used the AWS default credential chain
(config.LoadDefaultConfig), ignoring the s3.* credential properties passed
to
the catalog. This forced callers targeting AWS SigV4 REST catalogs (S3
Tables,
Glue) to export AWS_* environment variables even when they supplied
credentials
via catalog properties.
Build a static credentials provider from s3.access-key-id /
s3.secret-access-key
/ s3.session-token when present, and only fall back to the default chain
otherwise. An explicitly provided aws.Config (WithAwsConfig) still wins.
This mirrors the same gap tracked for the Python client in
apache/iceberg-python#2070.
Signed-off-by: iremcaginyurtturk <[email protected]>
* test(catalog/rest): pin SigV4 props-cred signing; error on partial creds
Address review feedback:
- Add TestSigV4SignsWithPropsCredentials, which asserts the SigV4
Authorization
header is signed with the credentials from catalog properties. This pins
the
wiring: removing the cfg.Credentials assignment makes the test fail.
- staticCredsFromProps now returns an error when only one of the access-key
/
secret-access-key pair is set, instead of silently falling back to the
ambient
default identity. Neither set still falls back to the default chain.
Signed-off-by: iremcaginyurtturk <[email protected]>
* docs(catalog/rest): document SigV4 signing credential precedence
Address review feedback (minor): the SigV4 signing identity resolves as
WithAwsConfig > s3.* catalog properties > AWS default credential chain.
Document
this on WithSigV4 and in the rest.sigv4-enabled configuration reference.
Signed-off-by: iremcaginyurtturk <[email protected]>
* fix(catalog/rest): error on incomplete SigV4 creds via
ValidateStaticCredentials
Delegate staticCredsFromProps to
internal/awsconfig.ValidateStaticCredentials so
a token-only s3.session-token (no access/secret key) returns the
ErrIncompleteStaticCredentials sentinel instead of silently dropping to the
default credential chain. Add a token-only test case and assert the
sentinel via
errors.Is. Document the s3.* vs Java rest.* divergence in the WithSigV4
godoc and
configuration.md.
* feat(catalog/rest): accept Java rest.* SigV4 credential aliases
Accept rest.access-key-id / rest.secret-access-key / rest.session-token as
aliases for the s3.* signing-credential properties, resolved per field with
the
s3.* keys taking precedence. This lets operators migrating from the Java
client
configure SigV4 signing with the property names they already use instead of
silently falling through to the AWS default credential chain.
* fix(catalog/rest): harden SigV4 credential boundaries
Two credential-boundary fixes for the props-based SigV4 signing path:
- Resolve the credential tuple atomically per namespace. Resolving each
field
independently let a partial s3.* pair be completed with a rest.* field (or
inherit an unrelated rest.* token), signing as a hybrid identity that
still
passed validation. Now if any s3.* field is set we validate and use only
the
s3.* tuple, otherwise only the rest.* tuple; never backfill across
namespaces.
- Do not re-sign a cross-origin redirect. sessionTransport signed every
hop, so
a redirect to a different origin exposed the SigV4 Authorization header
and
session token to an unconfigured host. Signing is now skipped when the
request
origin (scheme/host/effective port) differs from the configured catalog
origin.
Tests cover the mixed-partial and s3-pair-plus-rest-token cases and assert a
cross-origin redirect target receives neither the Authorization header nor
the
session token. Also closes a response body left open in an existing test.
Signed-off-by: iremcaginyurtturk <[email protected]>
---------
Signed-off-by: iremcaginyurtturk <[email protected]>
---
catalog/rest/options.go | 8 ++
catalog/rest/rest.go | 75 +++++++++++++++++-
catalog/rest/rest_internal_test.go | 155 +++++++++++++++++++++++++++++++++++++
website/src/configuration.md | 2 +-
4 files changed, 237 insertions(+), 3 deletions(-)
diff --git a/catalog/rest/options.go b/catalog/rest/options.go
index c52d48222..bf25b499d 100644
--- a/catalog/rest/options.go
+++ b/catalog/rest/options.go
@@ -88,6 +88,14 @@ func WithMetadataLocation(loc string) Option {
}
}
+// WithSigV4 enables AWS SigV4 request signing for the REST catalog. The
signing
+// identity is resolved in order: an explicit WithAwsConfig, then the s3.*
catalog
+// credential properties (s3.access-key-id / s3.secret-access-key /
s3.session-token),
+// then the AWS default credential chain.
+//
+// The Java-client property names (rest.access-key-id / rest.secret-access-key
/
+// rest.session-token) are accepted as aliases, resolved per field with the
s3.*
+// keys taking precedence when both are set.
func WithSigV4() Option {
return func(o *options) {
o.enableSigv4 = true
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 2f5af16df..be4b80d79 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -41,6 +41,7 @@ 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"
@@ -49,6 +50,7 @@ import (
"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"
@@ -92,6 +94,12 @@ 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
@@ -243,6 +251,31 @@ type sessionTransport struct {
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.
+ signingOrigin *url.URL
+}
+
+// sameOrigin reports whether two URLs share scheme, host, and effective port.
+func sameOrigin(a, b *url.URL) bool {
+ return strings.EqualFold(a.Scheme, b.Scheme) &&
+ strings.EqualFold(a.Hostname(), b.Hostname()) &&
+ defaultedPort(a) == defaultedPort(b)
+}
+
+func defaultedPort(u *url.URL) string {
+ if p := u.Port(); p != "" {
+ return p
+ }
+ switch strings.ToLower(u.Scheme) {
+ case "https":
+ return "443"
+ case "http":
+ return "80"
+ default:
+ return ""
+ }
}
// from
https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/aws/signer/v4#Signer.SignHTTP
@@ -292,7 +325,7 @@ func (s *sessionTransport) RoundTrip(r *http.Request)
(*http.Response, error) {
r.Header.Set(k, v)
}
- if s.signer != nil {
+ if s.signer != nil && (s.signingOrigin == nil ||
sameOrigin(s.signingOrigin, r.URL)) {
var payloadHash string
if r.Body == nil {
payloadHash = emptyStringHash
@@ -1107,14 +1140,24 @@ func (r *Catalog) createSession(ctx context.Context,
opts *options) (*http.Clien
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.
- var err error
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
@@ -1122,11 +1165,39 @@ func (r *Catalog) createSession(ctx context.Context,
opts *options) (*http.Clien
session.cfg, session.service = cfg, opts.sigv4Service
session.signer, session.newHash = v4.NewSigner(), sha256.New
+ 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 880fa1e65..1018c1965 100644
--- a/catalog/rest/rest_internal_test.go
+++ b/catalog/rest/rest_internal_test.go
@@ -42,6 +42,8 @@ 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/table"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
@@ -52,6 +54,159 @@ import (
"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")
+
+ _, 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: 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) {
+ var secondHit bool
+ var gotAuth, gotToken string
+ second := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
+ secondHit = true
+ gotAuth = r.Header.Get("Authorization")
+ gotToken = r.Header.Get("X-Amz-Security-Token")
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer second.Close()
+
+ 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{}})
+ })
+ mux.HandleFunc("/redirect", func(w http.ResponseWriter, r
*http.Request) {
+ firstAuth = r.Header.Get("Authorization")
+ http.Redirect(w, r, second.URL+"/landing",
http.StatusTemporaryRedirect)
+ })
+ 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",
+ }))
+ require.NoError(t, err)
+
+ req, err := http.NewRequestWithContext(context.Background(),
http.MethodGet, first.URL+"/redirect", nil)
+ require.NoError(t, err)
+ resp, err := cat.cl.Do(req)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+
+ require.Contains(t, firstAuth, "Credential=AKIDEXAMPLEPROPS/", "the
configured origin must still 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, gotToken, "the redirect target must not receive the
session token")
+}
+
func TestSplitIdentForPathRequiresNamespaceAndName(t *testing.T) {
cat := &Catalog{}
diff --git a/website/src/configuration.md b/website/src/configuration.md
index f722901f2..76807f304 100644
--- a/website/src/configuration.md
+++ b/website/src/configuration.md
@@ -56,7 +56,7 @@ catalog:
| `catalog.<name>.aws-profile` | AWS named profile for the Glue catalog. When
unset, the AWS SDK default credential chain is used. |
| `catalog.<name>.sql-driver` | `database/sql` driver name for the SQL
catalog. Maps to the `sql.driver` property. The default CLI binary only
compiles in `sqliteshim`; other drivers require a custom build. |
| `catalog.<name>.sql-dialect` | SQL dialect for the SQL catalog (`postgres`,
`mysql`, `sqlite`, `mssql`, `oracle`). Maps to the `sql.dialect` property. The
default CLI binary only ships `sqlite` via `sqliteshim`; other dialects need a
custom build with their drivers. |
-| `catalog.<name>.rest.sigv4-enabled` | Enable AWS SigV4 signing for REST. |
+| `catalog.<name>.rest.sigv4-enabled` | Enable AWS SigV4 signing for REST.
When enabled, requests are signed with the `s3.*` credential properties if set
(`s3.access-key-id` / `s3.secret-access-key` / `s3.session-token`), otherwise
with the AWS default credential chain. The Java-client names
(`rest.access-key-id` / `rest.secret-access-key` / `rest.session-token`) are
accepted as aliases, resolved per field with the `s3.*` keys taking precedence.
|
| `catalog.<name>.rest.signing-name` | SigV4 service name. |
| `catalog.<name>.rest.signing-region` | SigV4 region. |