mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-16 20:26:45 +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.
179 lines
6.0 KiB
Go
179 lines
6.0 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestTablePrefixSessionPolicyScopesToTheTable(t *testing.T) {
|
|
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events")
|
|
if err != nil {
|
|
t.Fatalf("tablePrefixSessionPolicy() error = %v", err)
|
|
}
|
|
|
|
var policy struct {
|
|
Statement []struct {
|
|
Effect string `json:"Effect"`
|
|
Action []string `json:"Action"`
|
|
Resource []string `json:"Resource"`
|
|
} `json:"Statement"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
|
t.Fatalf("policy is not valid JSON: %v", err)
|
|
}
|
|
// objects, conditioned bucket listing, and unconditioned location lookup
|
|
if len(policy.Statement) != 3 {
|
|
t.Fatalf("policy has %d statements, want 3", len(policy.Statement))
|
|
}
|
|
|
|
objects := policy.Statement[0]
|
|
if objects.Resource[0] != "arn:aws:s3:::warehouse/analytics/events/*" {
|
|
t.Errorf("object resource = %q, want the table prefix only", objects.Resource[0])
|
|
}
|
|
if policy.Statement[1].Resource[0] != "arn:aws:s3:::warehouse" {
|
|
t.Errorf("bucket resource = %q", policy.Statement[1].Resource[0])
|
|
}
|
|
for _, statement := range policy.Statement {
|
|
if statement.Effect != "Allow" {
|
|
t.Errorf("statement effect = %q, want Allow", statement.Effect)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A table at the bucket root has no prefix to scope to, and vending would hand
|
|
// out read and write over every other table in the bucket.
|
|
func TestTablePrefixSessionPolicyRefusesAWholeBucket(t *testing.T) {
|
|
if _, err := tablePrefixSessionPolicy("warehouse", ""); err == nil {
|
|
t.Error("tablePrefixSessionPolicy() error = nil, want a refusal for an empty prefix")
|
|
}
|
|
if _, err := tablePrefixSessionPolicy("warehouse", "/"); err == nil {
|
|
t.Error("a prefix of only separators was accepted")
|
|
}
|
|
}
|
|
|
|
// GetBucketLocation carries no prefix, so conditioning it would deny location
|
|
// discovery to every vended credential.
|
|
func TestTablePrefixSessionPolicyLeavesBucketLocationUnconditioned(t *testing.T) {
|
|
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events")
|
|
if err != nil {
|
|
t.Fatalf("tablePrefixSessionPolicy() error = %v", err)
|
|
}
|
|
|
|
var policy struct {
|
|
Statement []struct {
|
|
Action []string `json:"Action"`
|
|
Condition map[string]any `json:"Condition"`
|
|
} `json:"Statement"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
|
t.Fatalf("policy is not valid JSON: %v", err)
|
|
}
|
|
|
|
for _, statement := range policy.Statement {
|
|
for _, action := range statement.Action {
|
|
if action == "s3:GetBucketLocation" && statement.Condition != nil {
|
|
t.Errorf("s3:GetBucketLocation carries a condition it can never satisfy: %v", statement.Condition)
|
|
}
|
|
if action == "s3:ListBucketMultipartUploads" {
|
|
t.Error("s3:ListBucketMultipartUploads is granted; it cannot be scoped by prefix")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIcebergSessionNameIsBounded(t *testing.T) {
|
|
name := icebergSessionName(strings.Repeat("principal", 10), "warehouse", "ns/table")
|
|
if len(name) > 64 {
|
|
t.Errorf("session name is %d chars, want at most 64", len(name))
|
|
}
|
|
for _, r := range name {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
|
default:
|
|
t.Errorf("session name %q contains %q, which AWS rejects", name, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Vending stays off until an operator names a role, so the catalog keeps
|
|
// telling clients to use their own credentials.
|
|
func TestVendTableCredentialsDisabledWithoutRole(t *testing.T) {
|
|
s3a := &S3ApiServer{}
|
|
credentials, err := s3a.VendTableCredentials(context.Background(), "admin", "warehouse", "ns/t")
|
|
if err != nil {
|
|
t.Fatalf("VendTableCredentials() error = %v, want nil", err)
|
|
}
|
|
if credentials != nil {
|
|
t.Errorf("VendTableCredentials() = %v, want nil while no role is configured", credentials)
|
|
}
|
|
}
|
|
|
|
func TestVendTableCredentialsNeedsSTS(t *testing.T) {
|
|
s3a := &S3ApiServer{}
|
|
s3a.SetIcebergCredentialRole("arn:aws:iam::role/IcebergTableAccess", 3600)
|
|
|
|
if _, err := s3a.VendTableCredentials(context.Background(), "admin", "warehouse", "ns/t"); err == nil {
|
|
t.Error("VendTableCredentials() error = nil, want an error when STS is not configured")
|
|
}
|
|
}
|
|
|
|
// Listing is granted on the bucket, so the prefix condition is what keeps a
|
|
// vended credential from enumerating other tables.
|
|
func TestTablePrefixSessionPolicyRestrictsListing(t *testing.T) {
|
|
raw, err := tablePrefixSessionPolicy("warehouse", "analytics/events")
|
|
if err != nil {
|
|
t.Fatalf("tablePrefixSessionPolicy() error = %v", err)
|
|
}
|
|
|
|
var policy struct {
|
|
Statement []struct {
|
|
Action []string `json:"Action"`
|
|
Condition map[string]map[string][]string `json:"Condition"`
|
|
} `json:"Statement"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
|
t.Fatalf("policy is not valid JSON: %v", err)
|
|
}
|
|
|
|
var bucketStatement *struct {
|
|
Action []string `json:"Action"`
|
|
Condition map[string]map[string][]string `json:"Condition"`
|
|
}
|
|
for i := range policy.Statement {
|
|
for _, action := range policy.Statement[i].Action {
|
|
if action == "s3:ListBucket" {
|
|
bucketStatement = &policy.Statement[i]
|
|
}
|
|
}
|
|
}
|
|
if bucketStatement == nil {
|
|
t.Fatal("no statement grants s3:ListBucket")
|
|
}
|
|
|
|
prefixes := bucketStatement.Condition["StringLike"]["s3:prefix"]
|
|
want := map[string]bool{"analytics/events": true, "analytics/events/*": true}
|
|
if len(prefixes) != len(want) {
|
|
t.Fatalf("s3:prefix = %v, want %v", prefixes, want)
|
|
}
|
|
for _, prefix := range prefixes {
|
|
if !want[prefix] {
|
|
t.Errorf("s3:prefix contains %q, which is outside the table", prefix)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A location carrying a wildcard would widen the resource pattern to sibling
|
|
// tables, so it is refused instead of vended.
|
|
func TestTablePrefixSessionPolicyRefusesWildcards(t *testing.T) {
|
|
for _, prefix := range []string{"analytics/*", "analytics/ev?nts", "*"} {
|
|
if _, err := tablePrefixSessionPolicy("warehouse", prefix); err == nil {
|
|
t.Errorf("tablePrefixSessionPolicy(%q) error = nil, want a refusal", prefix)
|
|
}
|
|
}
|
|
if _, err := tablePrefixSessionPolicy("ware*house", "analytics/events"); err == nil {
|
|
t.Error("a wildcard in the bucket name was accepted")
|
|
}
|
|
}
|