Files
seaweedfs/weed/s3api/iceberg/iceberg_access_delegation_test.go
T
Chris Lu d448e9db7b iceberg: withhold the S3 endpoint from credential-vending clients (#10570)
* iceberg: withhold the S3 endpoint from credential-vending clients

A client that sends X-Iceberg-Access-Delegation: vended-credentials builds
its storage credential out of the LoadTable config and drops the one it was
configured with. We vend no credentials, so the endpoint we advertised left
DuckDB signing nothing: every metadata and data file came back 403, and its
attempt to refresh the empty credential 404ed on stage-created tables.

Answer those clients with no config at all so they keep their own
credentials. Clients that do not ask for delegation still get the endpoint.

* iceberg: mark load responses as varying on the delegation header

The FileIO config in a table or view load response now depends on whether
the client asked for vended credentials, so a cache between us and the
client must key on that header rather than on the URL alone.

* test: cover the DuckDB vended-credentials access pattern

Runs weed mini with -s3.externalUrl, which is what makes the catalog
advertise an endpoint at all, and checks both halves: a plain LoadTable
still gets the endpoint, while one asking for vended credentials never gets
an endpoint without the credentials to sign with. The DuckDB round trip
creates a table from a query and reads it back, which is the flow that
failed with 403 on every data file.
2026-08-04 18:14:34 -07:00

80 lines
3.1 KiB
Go

package iceberg
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func TestWantsVendedCredentials(t *testing.T) {
tests := []struct {
name string
values []string
want bool
}{
{name: "no header", want: false},
{name: "vended credentials", values: []string{"vended-credentials"}, want: true},
{name: "mechanism list", values: []string{"remote-signing,vended-credentials"}, want: true},
{name: "spaced and mixed case", values: []string{"Remote-Signing, Vended-Credentials"}, want: true},
{name: "repeated header", values: []string{"remote-signing", "vended-credentials"}, want: true},
{name: "remote signing only", values: []string{"remote-signing"}, want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
for _, v := range tc.values {
r.Header.Add(accessDelegationHeader, v)
}
if got := wantsVendedCredentials(r); got != tc.want {
t.Fatalf("wantsVendedCredentials(%v) = %v, want %v", tc.values, got, tc.want)
}
})
}
}
// A client that asked for vended credentials replaces its own storage
// credentials with whatever the catalog returns. Since the catalog vends none,
// an endpoint by itself would leave the client signing nothing and every data
// file read and write would come back 403.
func TestBuildFileIOConfigWithholdsEndpointFromCredentialVendingClients(t *testing.T) {
s := &Server{s3Endpoint: "http://seaweed.example:8333"}
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
if got := s.buildFileIOConfig(r); len(got) != 0 {
t.Fatalf("buildFileIOConfig() = %v, want empty for a vended-credentials request", got)
}
plain := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
if got := s.buildFileIOConfig(plain); got["s3.endpoint"] != s.s3Endpoint {
t.Fatalf("s3.endpoint = %q, want %q", got["s3.endpoint"], s.s3Endpoint)
}
}
// The body of a load response depends on the delegation header, so a cache in
// front of the catalog must not key on the URL alone.
func TestWriteLoadResultVariesOnTheDelegationHeader(t *testing.T) {
rec := httptest.NewRecorder()
writeLoadResult(rec, http.StatusOK, LoadTableResult{})
if got := rec.Header().Get("Vary"); got != accessDelegationHeader {
t.Fatalf("Vary = %q, want %q", got, accessDelegationHeader)
}
}
func TestLoadTableResultOmitsConfigForCredentialVendingClients(t *testing.T) {
s := &Server{s3Endpoint: "http://seaweed.example:8333"}
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
getResp := s3tables.GetTableResponse{MetadataLocation: "s3://bkt/ns/t/metadata/v1.metadata.json"}
result, err := s.buildLoadTableResult(r, getResp, "bkt", []string{"ns"}, "t")
if err != nil {
t.Fatalf("buildLoadTableResult() error = %v", err)
}
if len(result.Config) != 0 {
t.Fatalf("LoadTableResult.Config = %v, want empty", result.Config)
}
}