Files
seaweedfs/weed/s3api/s3api_iceberg_credentials.go
Chris LuandGitHub 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

178 lines
5.9 KiB
Go

package s3api
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
)
// icebergVendedCredentialsProvider names the origin recorded on sessions the
// catalog mints, so audit trails can tell them apart from ordinary logins.
const icebergVendedCredentialsProvider = "iceberg-catalog"
// SetIcebergCredentialRole configures the role the Iceberg catalog assumes when
// a client asks for vended credentials. Vending stays off until it is set.
func (s3a *S3ApiServer) SetIcebergCredentialRole(roleArn string, durationSeconds int64) {
s3a.icebergCredentialRole = strings.TrimSpace(roleArn)
s3a.icebergCredentialDuration = durationSeconds
}
// VendedCredentials are short-lived S3 credentials scoped to one table.
type VendedCredentials struct {
AccessKeyID string
SecretAccessKey string
SessionToken string
Expiration time.Time
}
// VendTableCredentials mints credentials that can read and write exactly one
// table's prefix, for a caller the Iceberg catalog has already authenticated
// and authorized. Returns nil when no role is configured, which leaves the
// catalog telling the client to keep using its own credentials.
func (s3a *S3ApiServer) VendTableCredentials(ctx context.Context, principal, bucket, prefix string) (*VendedCredentials, error) {
if s3a.icebergCredentialRole == "" {
return nil, nil
}
if principal == "" {
return nil, fmt.Errorf("no principal to vend credentials for")
}
if bucket == "" {
return nil, fmt.Errorf("no bucket to scope credentials to")
}
stsService := s3a.stsService()
if stsService == nil {
return nil, fmt.Errorf("STS is not configured")
}
policy, err := tablePrefixSessionPolicy(bucket, prefix)
if err != nil {
return nil, err
}
request := &sts.AssumeRoleForPrincipalRequest{
RoleArn: s3a.icebergCredentialRole,
Principal: principal,
RoleSessionName: icebergSessionName(principal, bucket, prefix),
ProviderName: icebergVendedCredentialsProvider,
Policy: &policy,
}
if s3a.icebergCredentialDuration > 0 {
duration := s3a.icebergCredentialDuration
request.DurationSeconds = &duration
}
resp, err := stsService.AssumeRoleForPrincipal(ctx, request)
if err != nil {
return nil, err
}
if resp == nil || resp.Credentials == nil {
return nil, fmt.Errorf("STS returned no credentials")
}
return &VendedCredentials{
AccessKeyID: resp.Credentials.AccessKeyId,
SecretAccessKey: resp.Credentials.SecretAccessKey,
SessionToken: resp.Credentials.SessionToken,
Expiration: resp.Credentials.Expiration,
}, nil
}
func (s3a *S3ApiServer) stsService() *sts.STSService {
if s3a.iam == nil || s3a.iam.iamIntegration == nil {
return nil
}
provider, ok := s3a.iam.iamIntegration.(IAMManagerProvider)
if !ok {
return nil
}
manager := provider.GetIAMManager()
if manager == nil {
return nil
}
return manager.GetSTSService()
}
// tablePrefixSessionPolicy narrows a session to one table's files plus the
// bucket listing that clients need to resolve them.
func tablePrefixSessionPolicy(bucket, prefix string) (string, error) {
prefix = strings.Trim(prefix, "/")
// A resource pattern is matched with wildcards, so a location carrying one
// would widen the session to sibling tables. Refuse rather than escape it:
// nothing SeaweedFS generates contains these.
if strings.ContainsAny(bucket, "*?") || strings.ContainsAny(prefix, "*?") {
return "", fmt.Errorf("refusing to scope credentials to a location with wildcards: s3://%s/%s", bucket, prefix)
}
// Without a prefix there is nothing to scope to, and the session would carry
// read and write over every table in the bucket. A table registered at the
// bucket root is the only way to get here.
if prefix == "" {
return "", fmt.Errorf("refusing to scope credentials to a whole bucket: s3://%s", bucket)
}
policy := map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{
"Effect": "Allow",
"Action": []string{"s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"},
"Resource": []string{fmt.Sprintf("arn:aws:s3:::%s/%s/*", bucket, prefix)},
},
{
// Listing is granted on the bucket, so without the condition the
// session could enumerate every other table's object names.
"Effect": "Allow",
"Action": []string{"s3:ListBucket"},
"Resource": []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)},
"Condition": map[string]interface{}{
"StringLike": map[string]interface{}{
"s3:prefix": []string{prefix, prefix + "/*"},
},
},
},
{
// GetBucketLocation carries no prefix to condition on, so it sits
// in its own statement rather than being denied by one.
// ListBucketMultipartUploads is deliberately absent: 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.
"Effect": "Allow",
"Action": []string{"s3:GetBucketLocation"},
"Resource": []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)},
},
},
}
encoded, err := json.Marshal(policy)
if err != nil {
return "", fmt.Errorf("build session policy: %w", err)
}
return string(encoded), nil
}
// icebergSessionName builds a session name that identifies the caller and the
// table in audit logs, within the 64-character AWS limit.
func icebergSessionName(principal, bucket, prefix string) string {
name := fmt.Sprintf("iceberg-%s-%s", principal, strings.ReplaceAll(strings.Trim(prefix, "/"), "/", "-"))
if strings.Trim(prefix, "/") == "" {
name = fmt.Sprintf("iceberg-%s-%s", principal, bucket)
}
name = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
return r
default:
return '-'
}
}, name)
if len(name) > 64 {
name = name[:64]
}
return name
}