mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-03 10:37:35 +01:00
* 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>
309 lines
11 KiB
Go
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")
|
|
}
|