Files
seaweedfs/weed/s3api/iceberg/iceberg_credential_vending_test.go
T
Chris Lu 7522e17b6d iceberg: vend table-scoped credentials to clients that ask for delegation (#10777)
* iceberg: vend table-scoped credentials to clients that ask for delegation

The catalog recognised X-Iceberg-Access-Delegation: vended-credentials
and then deliberately said nothing, because it had nothing to vend: it
withheld even the S3 endpoint so the client would keep the credentials it
was configured with. That left every engine expecting the catalog to hand
out access - Snowflake, Databricks, Trino with vending, any multi-tenant
setup - needing static S3 keys distributed out of band.

Mint an STS session per request instead, scoped by a session policy to
the table's own prefix plus the bucket listing needed to resolve it, and
return it in the load response config and storage-credentials. The role
to assume is named by -s3.iceberg.credentialRole; its trust policy is
what decides whether a caller may assume it, and vending stays off until
it is set. A failed mint falls back to the old silence rather than
handing back an endpoint the client cannot sign for.

* iceberg: keep vended credentials inside the table prefix

Review follow-ups on credential vending:

Listing was granted on the bucket ARN with no condition, so a credential
vended for one table could enumerate every other table's object names.
Constrain s3:prefix to the table's own prefix, which the S3 gateway
already populates for list requests.

A table location carrying * or ? would have gone into the policy's
resource pattern unescaped and widened the session to sibling prefixes.
Refuse to vend for such a location rather than escaping it; nothing the
catalog generates contains those characters.

DurationSeconds skipped the 900..43200 bounds the other assume-role paths
enforce, so -s3.iceberg.credentialDurationSeconds could ask for a session
outside them. The check is now shared by all three entry points.

* iceberg: return the vended credentials from buildFileIOConfig itself

buildStorageConfig was a second name for what buildFileIOConfig already
did; it now returns the storage credentials alongside the properties, and
callers that only want the properties drop them.

* iceberg: split the vended bucket grants, and refuse a whole-bucket scope

The prefix condition sat on a statement that also granted
GetBucketLocation and ListBucketMultipartUploads, neither of which carries
an s3:prefix to satisfy it, so both were denied for every vended
credential. GetBucketLocation moves to its own unconditioned statement.
ListBucketMultipartUploads is dropped: Iceberg writers complete and abort
by upload id, and granting it either leaks in-flight keys bucket-wide or
breaks on the same missing prefix.

A table whose location has no prefix - one registered at the bucket root -
would have been vended read and write over every other table in the
bucket. Refuse, the way a location with wildcards is refused.
2026-08-16 12:57:12 -07:00

112 lines
4.1 KiB
Go

package iceberg
import (
"context"
"errors"
"net/http/httptest"
"testing"
"time"
)
type stubVendor struct {
credentials *VendedCredentials
err error
gotBucket string
gotPrefix string
gotcaller string
}
func (v *stubVendor) VendTableCredentials(_ context.Context, principal, bucket, prefix string) (*VendedCredentials, error) {
v.gotcaller, v.gotBucket, v.gotPrefix = principal, bucket, prefix
return v.credentials, v.err
}
func vendingServer(vendor CredentialVendor) *Server {
s := &Server{s3Endpoint: "http://s3.example:8333"}
s.SetCredentialVendor(vendor)
return s
}
func TestBuildFileIOConfigVendingVendsScopedCredentials(t *testing.T) {
expiry := time.Now().Add(time.Hour).Truncate(time.Millisecond)
vendor := &stubVendor{credentials: &VendedCredentials{
AccessKeyID: "ASIAEXAMPLE",
SecretAccessKey: "secret",
SessionToken: "token",
Expiration: expiry,
}}
s := vendingServer(vendor)
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if vendor.gotBucket != "warehouse" || vendor.gotPrefix != "ns/t" {
t.Errorf("vendor called with bucket=%q prefix=%q, want warehouse and ns/t", vendor.gotBucket, vendor.gotPrefix)
}
if config["s3.access-key-id"] != "ASIAEXAMPLE" || config["s3.secret-access-key"] != "secret" || config["s3.session-token"] != "token" {
t.Errorf("config missing vended credentials: %v", config)
}
if config["s3.endpoint"] != "http://s3.example:8333" {
t.Errorf("config missing the endpoint the credentials are for: %v", config)
}
if len(storageCredentials) != 1 {
t.Fatalf("storage-credentials = %d entries, want 1", len(storageCredentials))
}
if storageCredentials[0].Prefix != "s3://warehouse/ns/t" {
t.Errorf("storage credential prefix = %q", storageCredentials[0].Prefix)
}
if storageCredentials[0].Config["s3.session-token"] != "token" {
t.Errorf("storage credential is missing the session token: %v", storageCredentials[0].Config)
}
}
// Without a vendor the response must stay silent about the endpoint too: a
// client that asked for vended credentials would otherwise drop its own and
// start sending unsigned requests.
func TestBuildFileIOConfigVendingWithoutVendorSaysNothing(t *testing.T) {
s := &Server{s3Endpoint: "http://s3.example:8333"}
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if len(config) != 0 || storageCredentials != nil {
t.Errorf("config = %v, storage-credentials = %v, want both empty", config, storageCredentials)
}
}
func TestBuildFileIOConfigVendingFallsBackWhenVendingFails(t *testing.T) {
s := vendingServer(&stubVendor{err: errors.New("no role configured")})
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
r.Header.Set(accessDelegationHeader, "vended-credentials")
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if len(config) != 0 || storageCredentials != nil {
t.Errorf("config = %v, storage-credentials = %v, want both empty", config, storageCredentials)
}
}
// A client that did not ask for delegation keeps the plain endpoint config and
// must never be handed credentials.
func TestBuildFileIOConfigVendingWithoutDelegationHeader(t *testing.T) {
vendor := &stubVendor{credentials: &VendedCredentials{AccessKeyID: "ASIAEXAMPLE"}}
s := vendingServer(vendor)
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
if _, vended := config["s3.access-key-id"]; vended {
t.Errorf("credentials vended without the delegation header: %v", config)
}
if config["s3.endpoint"] != "http://s3.example:8333" {
t.Errorf("config = %v, want the endpoint", config)
}
if storageCredentials != nil {
t.Errorf("storage-credentials = %v, want none", storageCredentials)
}
if vendor.gotBucket != "" {
t.Errorf("vendor was called for a request that did not ask for delegation")
}
}