Files
seaweedfs/weed/s3api/s3tables/handler_sts_legacy_owner_test.go
T
Chris LuandClaude Opus 5.5 813ac74d79 s3tables: keep legacy-subject owners and policy principals for federated STS sessions (#12615)
* s3tables: re-anchor legacy owners for federated STS sessions

A web-identity role session was keyed on its bare OIDC subject until
owners became issuer-scoped. Its sessions now carry the scoped aws:userid
but no asserted issuers, so the legacy subject check never applied and the
session lost the table buckets and namespaces it had created. Read the
configured issuer set for such sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: keep token claims from asserting trusted issuers on STS sessions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* iceberg: give the catalog's table handler the local accounts and issuer set

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: test a federated session on a bucket its legacy subject owns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* sts: drop a token-supplied aws:userid when a session has no parent user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: take a session's aws:userid from its signed parent user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test that session claims keep only the server-asserted aws:userid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: match legacy subject patterns in table bucket policy principals

A policy written before issuer scoping can name role sessions by a pattern
over their bare OIDC subjects. Match such a pattern against the caller's
subject claim once that subject resolves to the session's scoped principal
under the single configured issuer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: test a federated session against a legacy subject pattern principal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:43:52 -08:00

142 lines
7.0 KiB
Go

package s3tables
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A table bucket a web-identity role session created before owners were
// issuer-scoped is owned by the bare OIDC subject. The same subject's later
// sessions, signed with SigV4 session credentials, carry the scoped aws:userid
// and no asserted issuers, and must still own the bucket.
func TestSTSSessionKeepsLegacySubjectOwnedBucket(t *testing.T) {
const (
bucket = "lake-norm"
sub = "project_path:dev/infra/datalake/normalisation:ref_type:branch:ref:main"
issuer = "https://gitlab.example.com"
)
arn, err := BuildBucketARN(DefaultRegion, DefaultAccountID, bucket)
require.NoError(t, err)
body, err := json.Marshal(&CreateNamespaceRequest{TableBucketARN: arn, Namespace: []string{"some_ns"}})
require.NoError(t, err)
createNamespace := func(t *testing.T, sessionIssuer string, configured ...string) int {
fs := s3tablestest.Start(t)
metadata, err := json.Marshal(&tableBucketMetadata{Name: bucket, CreatedAt: time.Now(), OwnerAccountID: sub})
require.NoError(t, err)
fs.Put(TablesPath, bucket, map[string][]byte{ExtendedKeyTableBucket: []byte("true"), ExtendedKeyMetadata: metadata})
m := NewManager()
m.SetDefaultAllow(false)
if configured != nil {
m.SetTrustedIssuerSource(func(context.Context) []string { return configured })
}
identity := &testIdentity{
Name: sub,
Account: &testIdentityAccount{Id: sub},
Claims: map[string]interface{}{
"sub": sub,
"aws:userid": sts.ComputeParentUser(sub, sessionIssuer),
},
}
req, err := newManagerRequest(s3_constants.SetIdentityInContext(context.Background(), identity), "CreateNamespace", body, sub)
require.NoError(t, err)
recorder := httptest.NewRecorder()
m.handler.HandleRequest(recorder, req, NewManagerClient(fs.Client))
return recorder.Code
}
assert.Equal(t, http.StatusOK, createNamespace(t, issuer, issuer), "the subject's own session lost its bucket")
assert.Equal(t, http.StatusForbidden, createNamespace(t, issuer), "no configured issuer set must not re-anchor")
assert.Equal(t, http.StatusForbidden, createNamespace(t, issuer, issuer, "https://other.example.com"),
"a legacy subject is ambiguous once two issuers are configured")
assert.Equal(t, http.StatusForbidden, createNamespace(t, "https://other.example.com", issuer),
"another issuer's user with the same subject must not take the bucket")
}
// Issuers asserted by the bearer path win, even when empty; an identity that
// is not a federated session never reads the configured set.
func TestTrustedIssuersSourceOnlyForFederatedSessions(t *testing.T) {
h := NewS3TablesHandler()
h.SetTrustedIssuerSource(func(context.Context) []string { return []string{"configured"} })
issuersFor := func(claims map[string]interface{}) []string {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), &testIdentity{Claims: claims}))
return h.trustedIssuers(req)
}
scoped := sts.ComputeParentUser("alice", "configured")
assert.Equal(t, []string{"configured"}, issuersFor(map[string]interface{}{"aws:userid": scoped}))
assert.Equal(t, []string{"asserted"}, issuersFor(map[string]interface{}{"aws:userid": scoped, "seaweed:oidc-issuers": "asserted"}))
assert.Empty(t, issuersFor(map[string]interface{}{"aws:userid": scoped, "seaweed:oidc-issuers": ""}))
assert.Empty(t, issuersFor(map[string]interface{}{"aws:userid": "alice"}))
assert.Empty(t, issuersFor(map[string]interface{}{"sub": "alice"}))
}
// A table bucket policy written before issuer scoping names role sessions by
// a pattern over their bare subjects. A session whose subject matches it keeps
// the grant; a subject that does not hash to the session's aws:userid does not.
func TestSTSSessionMatchesLegacySubjectPatternPrincipal(t *testing.T) {
const (
bucket = "lake-norm"
sub = "project_path:dev/infra/datalake/normalisation:ref_type:branch:ref:main"
issuer = "https://gitlab.example.com"
)
arn, err := BuildBucketARN(DefaultRegion, DefaultAccountID, bucket)
require.NoError(t, err)
body, err := json.Marshal(&CreateNamespaceRequest{TableBucketARN: arn, Namespace: []string{"some_ns"}})
require.NoError(t, err)
policy := func(principal string) []byte {
return []byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":["` + principal +
`"],"Action":["s3tables:CreateNamespace"],"Resource":"*"}]}`)
}
createNamespace := func(t *testing.T, principalSpec, claimedSub, scopedSub, sessionIssuer string, configured ...string) int {
fs := s3tablestest.Start(t)
metadata, err := json.Marshal(&tableBucketMetadata{Name: bucket, CreatedAt: time.Now(), OwnerAccountID: s3_constants.AccountAdminId})
require.NoError(t, err)
fs.Put(TablesPath, bucket, map[string][]byte{
ExtendedKeyTableBucket: []byte("true"),
ExtendedKeyMetadata: metadata,
ExtendedKeyPolicy: policy(principalSpec),
})
m := NewManager()
m.SetDefaultAllow(false)
if configured != nil {
m.SetTrustedIssuerSource(func(context.Context) []string { return configured })
}
scoped := sts.ComputeParentUser(scopedSub, sessionIssuer)
identity := &testIdentity{
Name: scoped,
Account: &testIdentityAccount{Id: scoped},
Claims: map[string]interface{}{"sub": claimedSub, "aws:userid": scoped},
}
req, err := newManagerRequest(s3_constants.SetIdentityInContext(context.Background(), identity), "CreateNamespace", body, scoped)
require.NoError(t, err)
recorder := httptest.NewRecorder()
m.handler.HandleRequest(recorder, req, NewManagerClient(fs.Client))
return recorder.Code
}
const pattern = "project_path:dev/infra/datalake/normalisation:*"
assert.Equal(t, http.StatusOK, createNamespace(t, pattern, sub, sub, issuer, issuer), "the subject pattern no longer grants")
assert.Equal(t, http.StatusOK, createNamespace(t, sub, sub, sub, issuer, issuer), "the exact subject no longer grants")
assert.Equal(t, http.StatusForbidden, createNamespace(t, pattern, sub, sub, issuer), "no configured issuer set must not re-anchor")
assert.Equal(t, http.StatusForbidden, createNamespace(t, pattern, sub, sub, issuer, issuer, "https://other.example.com"),
"a subject pattern is ambiguous once two issuers are configured")
assert.Equal(t, http.StatusForbidden, createNamespace(t, pattern, sub, sub, "https://other.example.com", issuer),
"another issuer's user with the same subject must not match")
assert.Equal(t, http.StatusForbidden, createNamespace(t, pattern, sub, "project_path:other/repo:ref_type:branch:ref:main", issuer, issuer),
"a subject claim the session's aws:userid was not derived from must not match")
assert.Equal(t, http.StatusForbidden, createNamespace(t, "arn:aws:iam::*", sub, sub, issuer, issuer),
"an ARN pattern is not a subject pattern")
}