mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-17 12:46:59 +00:00
* 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.
178 lines
5.9 KiB
Go
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
|
|
}
|