Files
seaweedfs/weed/s3api/s3_iam_bearer_trust_policy_test.go
T
Chris LuandDevin 446e6f3444 s3tables: scope OIDC-derived account IDs to the token issuer (#11774)
* s3tables: scope OIDC-derived account IDs to the token issuer

Table ownership keyed off the bare sub claim, so two identity providers
issuing the same sub collided on one account - a caller could act on
tables owned by a different provider's user. OIDC bearer tokens now
carry the same issuer-namespaced (sub, iss) hash STS sessions already
expose as aws:userid, and getAccountID prefers it over the raw claims.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3tables: keep legacy owners working and scrub client-subject claims

- OIDC bearer tokens without a subject are rejected, and a token-supplied
  aws:userid claim is always overwritten with the verified (sub, iss)
  hash, so a forged claim can no longer stand in for the principal.
- Owners written before issuer scoping hold a bare subject; permission
  checks now re-anchor that subject under the caller's issuer, so the
  same (sub, iss) pair keeps its ownership while scoped owners remain
  issuer-isolated. The token issuer is asserted into claims alongside
  sub and carried through PolicyContext.

* s3tables: anchor legacy subjects to the configured issuer set

* s3: enumerate trusted issuers from registered providers too

* s3: fail closed when the OIDC provider store cannot be listed

trustedOIDCIssuers kept the registered issuers when ListOIDCProviders
failed, presenting a partial set as complete. A deployment trusting one
registered issuer A plus a stored issuer B would, on a listing error,
report only A — the single-issuer shape under which a legacy bare
subject re-anchors — letting a caller from A claim a stored subject
that belongs to B and gain owner access.

With no provider store the registered providers are provably complete,
so keep returning them; with a store, a failed listing means the real
set is unknown, so return none.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-16 19:48:32 -07:00

309 lines
11 KiB
Go

package s3api
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A raw OIDC token sent as Authorization: Bearer must clear the same role
// trust-policy gate as AssumeRoleWithWebIdentity. The provider's role mapping
// alone must not mint a principal for a role whose trust policy rejects the
// token's federated provider.
func TestOIDCBearerEnforcesRoleTrustPolicy(t *testing.T) {
ctx := context.Background()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
jwks := map[string]interface{}{
"keys": []map[string]interface{}{{
"kty": "RSA",
"kid": "bearer-test-key",
"use": "sig",
"alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
"e": "AQAB",
}},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/jwks" {
json.NewEncoder(w).Encode(jwks)
}
}))
defer server.Close()
provider := oidc.NewOIDCProvider("test-oidc")
require.NoError(t, provider.Initialize(&oidc.OIDCConfig{
Issuer: server.URL,
ClientID: "test-client",
JWKSUri: server.URL + "/jwks",
RoleMapping: &providers.RoleMapping{
Rules: []providers.MappingRule{
{Claim: "groups", Value: "denied-group", Role: "arn:aws:iam::role/BearerDeniedRole"},
{Claim: "groups", Value: "trusted-group", Role: "arn:aws:iam::role/BearerTrustedRole"},
},
},
}))
iamManager := integration.NewIAMManager()
require.NoError(t, iamManager.Initialize(&integration.IAMConfig{
STS: &sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
Issuer: "test-sts",
SigningKey: []byte("test-signing-key-32-characters-long"),
},
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
Roles: &integration.RoleStoreConfig{StoreType: "memory"},
}, func() string { return "localhost:8888" }))
require.NoError(t, iamManager.RegisterIdentityProvider(provider))
require.NoError(t, iamManager.CreatePolicy(ctx, "", "BearerTestPolicy", &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Action: []string{"s3:*"},
Resource: []string{"arn:aws:s3:::*", "arn:aws:s3:::*/*"},
}},
}))
trustPolicy := func(federatedProvider string) *policy.PolicyDocument {
return &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Principal: map[string]interface{}{"Federated": federatedProvider},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
}},
}
}
// trusts a different federated provider than the one issuing the token
require.NoError(t, iamManager.CreateRole(ctx, "", "BearerDeniedRole", &integration.RoleDefinition{
RoleName: "BearerDeniedRole",
TrustPolicy: trustPolicy("other-provider"),
AttachedPolicies: []string{"BearerTestPolicy"},
}))
require.NoError(t, iamManager.CreateRole(ctx, "", "BearerTrustedRole", &integration.RoleDefinition{
RoleName: "BearerTrustedRole",
TrustPolicy: trustPolicy("test-oidc"),
AttachedPolicies: []string{"BearerTestPolicy"},
}))
signJWT := func(groups ...string) string {
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": server.URL,
"sub": "bearer-user",
"aud": "test-client",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"groups": groups,
})
token.Header["kid"] = "bearer-test-key"
signed, err := token.SignedString(key)
require.NoError(t, err)
return signed
}
s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
authenticate := func(token string) (*IAMIdentity, s3err.ErrorCode) {
req := httptest.NewRequest("GET", "/test-bucket/test-object", http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
return s3iam.AuthenticateJWT(ctx, req)
}
deniedToken := signJWT("denied-group")
// negative control: STS refuses to issue a session for this token and role
_, err = iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/BearerDeniedRole",
WebIdentityToken: deniedToken,
RoleSessionName: "trust-policy-check",
})
require.ErrorContains(t, err, "trust policy")
// the direct bearer path must reject the same token
identity, errCode := authenticate(deniedToken)
assert.Equal(t, s3err.ErrAccessDenied, errCode)
assert.Nil(t, identity)
// a role whose trust policy trusts the issuing provider still works
identity, errCode = authenticate(signJWT("trusted-group"))
require.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t, "arn:aws:iam::role/BearerTrustedRole", identity.Principal)
}
// An OIDC bearer token's aws:userid claim is server-asserted from the
// verified (sub, iss) pair: a token-supplied value is overwritten, and a
// token without a subject is rejected outright.
func TestOIDCBearerUserIDIsIssuerScoped(t *testing.T) {
ctx := context.Background()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
jwks := map[string]interface{}{
"keys": []map[string]interface{}{{
"kty": "RSA",
"kid": "bearer-test-key",
"use": "sig",
"alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
"e": "AQAB",
}},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/jwks" {
json.NewEncoder(w).Encode(jwks)
}
}))
defer server.Close()
provider := oidc.NewOIDCProvider("test-oidc")
require.NoError(t, provider.Initialize(&oidc.OIDCConfig{
Issuer: server.URL,
ClientID: "test-client",
JWKSUri: server.URL + "/jwks",
RoleMapping: &providers.RoleMapping{
Rules: []providers.MappingRule{
{Claim: "groups", Value: "trusted-group", Role: "arn:aws:iam::role/BearerTrustedRole"},
},
},
}))
iamManager := integration.NewIAMManager()
require.NoError(t, iamManager.Initialize(&integration.IAMConfig{
STS: &sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
Issuer: "test-sts",
SigningKey: []byte("test-signing-key-32-characters-long"),
},
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
Roles: &integration.RoleStoreConfig{StoreType: "memory"},
}, func() string { return "localhost:8888" }))
require.NoError(t, iamManager.RegisterIdentityProvider(provider))
require.NoError(t, iamManager.CreatePolicy(ctx, "", "BearerTestPolicy", &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Action: []string{"s3:*"},
Resource: []string{"arn:aws:s3:::*", "arn:aws:s3:::*/*"},
}},
}))
require.NoError(t, iamManager.CreateRole(ctx, "", "BearerTrustedRole", &integration.RoleDefinition{
RoleName: "BearerTrustedRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Principal: map[string]interface{}{"Federated": "test-oidc"},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
}},
},
AttachedPolicies: []string{"BearerTestPolicy"},
}))
sign := func(extra map[string]interface{}) string {
claims := jwt.MapClaims{
"iss": server.URL,
"aud": "test-client",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"groups": []string{"trusted-group"},
}
for k, v := range extra {
claims[k] = v
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = "bearer-test-key"
signed, err := token.SignedString(key)
require.NoError(t, err)
return signed
}
s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
authenticate := func(token string) (*IAMIdentity, s3err.ErrorCode) {
req := httptest.NewRequest("GET", "/test-bucket/test-object", http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
return s3iam.AuthenticateJWT(ctx, req)
}
identity, errCode := authenticate(sign(map[string]interface{}{
"sub": "bearer-user",
"aws:userid": "admin",
}))
require.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t,
sts.ComputeParentUser("bearer-user", server.URL),
identity.Claims["aws:userid"],
"the token-supplied aws:userid must be overwritten with the verified (sub, iss) hash")
assert.Equal(t, "bearer-user", identity.Claims["sub"])
assert.Equal(t, server.URL, identity.Claims["iss"])
assert.Equal(t, server.URL, identity.Claims["seaweed:oidc-issuers"],
"the registered provider's issuer must be asserted as trusted for legacy-owner anchoring")
_, errCode = authenticate(sign(map[string]interface{}{"sub": ""}))
assert.Equal(t, s3err.ErrAccessDenied, errCode, "a token with no subject must not authenticate")
}
// flakyListOIDCStore fails ListProviders on demand, standing in for a
// filer-backed provider store that cannot be read.
type flakyListOIDCStore struct {
integration.OIDCProviderStore
fail atomic.Bool
}
func (s *flakyListOIDCStore) ListProviders(ctx context.Context, filerAddress string) ([]*integration.OIDCProviderRecord, error) {
if s.fail.Load() {
return nil, fmt.Errorf("list OIDC providers: simulated outage")
}
return s.OIDCProviderStore.ListProviders(ctx, filerAddress)
}
// A failed store listing must not pass the registered providers off as the
// complete trusted set: hidden issuers could shrink the reported set to one
// entry, the only shape under which a legacy bare subject re-anchors.
func TestTrustedOIDCIssuersFailsClosedOnStoreError(t *testing.T) {
ctx := context.Background()
iamManager := integration.NewIAMManager()
s3iam := NewS3IAMIntegration(iamManager, "")
// No provider store at all: registered providers are the complete set —
// there is nowhere an issuer could hide behind a listing failure.
assert.Empty(t, s3iam.trustedOIDCIssuers(ctx))
mem := integration.NewMemoryOIDCProviderStore()
require.NoError(t, mem.StoreProvider(ctx, "", &integration.OIDCProviderRecord{
ARN: "arn:aws:iam::111122223333:oidc-provider/idp.example.com",
URL: "https://idp.example.com",
}))
store := &flakyListOIDCStore{OIDCProviderStore: mem}
iamManager.SetOIDCProviderStore(store)
assert.Equal(t, []string{"https://idp.example.com"}, s3iam.trustedOIDCIssuers(ctx))
store.fail.Store(true)
assert.Empty(t, s3iam.trustedOIDCIssuers(ctx),
"a failed listing must report no trusted issuers, not the partial registered set")
}