fix(iam): four phase-3 follow-ups (provider scoping, public path wrapper, static mirror, claim-mode RoleArn) (#9333)

* fix(iam): scope IAM-managed OIDC provider lookup by role account

Two account-scoped OIDC records sharing an issuer were collapsed into a
single map slot keyed only by the URL. The last-write-wins entry then
served every AssumeRoleWithWebIdentity, so a token destined for account
B's role could be validated by account A's record (its clientIDs and
thumbprints), defeating the per-account isolation the records exist for.

The role-account check in enforceProviderAccountScope still rejected
the cross-account assumption, but only after the wrong record's
audience and TLS pin had already accepted the token.

Refresh now keys IAM-managed records as (issuer, account), and
validation parses the requested role's account up front and matches
the record under that issuer in this order: exact account, global
(account-less), static-config fallback. An unknown account hint
deliberately skips account-scoped entries — picking one arbitrarily is
the bug this commit fixes — and falls through to global or static.

* fix(iam): route public AssumeRoleWithWebIdentity through IAMManager

handleAssumeRoleWithWebIdentity called stsService.AssumeRoleWithWebIdentity
directly, bypassing the IAMManager wrapper. The wrapper is where
enforceProviderAccountScope rejects cross-account assumption attempts
and capDurationByRole clamps to the role's MaxSessionDuration; both
silently became no-ops for any AWS-SDK caller hitting the public
endpoint.

Dispatch through the IAMManager (via the existing IAMManagerProvider
interface that other handlers in this file already use) when one is
wired. Embedded test setups without an IAM integration fall back to
the bare STS service unchanged.

* fix(iam): mirror thumbprints, principal-tag keys, and policy claim from static OIDC config

initOIDCProviderStore mirrored only URL and ClientIDs. Once
RefreshOIDCProvidersFromStore ran (on any IAM-managed mutation, or on
boot once the metadata-subscribe loop kicked in),
buildOIDCProviderFromRecord rebuilt the runtime provider from this
truncated record. Because IAM-managed entries take precedence over the
static-config map, the rebuild silently shadowed the bootstrap with a
weaker provider:

- Thumbprints: dropped, so TLS-pinned issuers fell back to the system
  trust store.
- AllowedPrincipalTagKeys: dropped, so principal-tag claims stopped
  reaching the session.
- PolicyClaim: dropped, so claim-based policy mode stopped triggering.

Pull all three from the provider's static Config map at mirror time so
the stored record round-trips to a runtime provider equivalent to the
one the static config produced directly.

* fix(iam): allow empty RoleArn in AssumeRoleWithWebIdentity HTTP handler

Phase 3b advertises that RoleArn MAY be omitted in claim-based policy
mode — the STS service then derives the assumed-role ARN from the
configured policy claim. The HTTP handler still rejected empty RoleArn
up front with MissingParameter, so SDK callers using the documented
omitted-role flow never reached the STS layer.

Drop the pre-check; STS still validates that claim-based mode is
configured and that the IDP emits policies, returning a precise error
when either is missing. The existing error mapping below this point
surfaces those as InvalidParameterValue, matching what an AWS SDK
expects.

* test(iam): update missing-RoleArn STS integration test for the new contract

The previous commit drops the HTTP-layer RoleArn pre-check so claim-based
mode can derive the ARN from a JWT claim. The integration test still
asserted MissingParameter for the missing-RoleArn case, which now
reaches the STS layer and surfaces a JWT-parse error instead. Update
the assertion to match: missing RoleArn alone must no longer surface
as MissingParameter, but a bogus JWT must still be rejected.
This commit is contained in:
Chris Lu
2026-05-05 19:14:44 -07:00
committed by GitHub
parent 9af1b212d3
commit 12f283357f
8 changed files with 444 additions and 55 deletions
+12 -4
View File
@@ -74,25 +74,33 @@ func TestAssumeRoleWithWebIdentityValidation(t *testing.T) {
assert.Equal(t, "MissingParameter", errResp.Error.Code)
})
t.Run("missing_role_arn", func(t *testing.T) {
t.Run("missing_role_arn_invalid_jwt_still_rejected", func(t *testing.T) {
// Missing RoleArn is no longer a fast-fail at the HTTP layer:
// claim-based policy mode (Phase 3b) advertises RoleArn as
// optional so the STS service can derive the assumed-role ARN
// from the configured policy claim. Validation now happens at
// the STS layer once the JWT is parsed. With a bogus token the
// JWT parse fails first, so the request is still rejected —
// just with the JWT-parse error code instead of MissingParameter.
resp, err := callSTSAPI(t, url.Values{
"Action": {"AssumeRoleWithWebIdentity"},
"WebIdentityToken": {"fake-jwt-token"},
"RoleSessionName": {"test-session"},
// RoleArn is missing
// RoleArn omitted on purpose.
})
require.NoError(t, err)
defer resp.Body.Close()
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
"Should fail without RoleArn")
"Should still fail when RoleArn is missing and the JWT is invalid")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp STSErrorTestResponse
err = xml.Unmarshal(body, &errResp)
require.NoError(t, err, "Failed to parse error response: %s", string(body))
assert.Equal(t, "MissingParameter", errResp.Error.Code)
assert.NotEqual(t, "MissingParameter", errResp.Error.Code,
"missing RoleArn alone must no longer surface as MissingParameter")
})
t.Run("missing_role_session_name", func(t *testing.T) {
+51 -12
View File
@@ -585,12 +585,15 @@ func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
createdAt = existing.CreatedAt
}
rec := &OIDCProviderRecord{
AccountID: accountID,
ARN: arn,
URL: issuer,
ClientIDs: clientIDs,
CreatedAt: createdAt,
UpdatedAt: now,
AccountID: accountID,
ARN: arn,
URL: issuer,
ClientIDs: clientIDs,
Thumbprints: extractStringList(pc.Config, "thumbprints"),
AllowedPrincipalTagKeys: extractStringList(pc.Config, "allowedPrincipalTagKeys"),
PolicyClaim: extractString(pc.Config, "policyClaim"),
CreatedAt: createdAt,
UpdatedAt: now,
}
if err := store.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
glog.Warningf("mirror static OIDC provider %s into store: %v", pc.Name, err)
@@ -625,7 +628,7 @@ func (m *IAMManager) RefreshOIDCProvidersFromStore(ctx context.Context) error {
if err != nil {
return fmt.Errorf("list OIDC providers: %w", err)
}
byIssuer := make(map[string]providers.IdentityProvider, len(records))
byIssuer := make(map[string][]sts.ScopedOIDCProvider, len(records))
for _, rec := range records {
if rec == nil || rec.URL == "" {
continue
@@ -635,12 +638,15 @@ func (m *IAMManager) RefreshOIDCProvidersFromStore(ctx context.Context) error {
glog.Warningf("skip refreshing OIDC provider %s: %v", rec.ARN, err)
continue
}
// Last write wins on issuer collision; the store is the source of
// truth, and an operator who has two records with the same issuer
// has already accepted one will shadow the other.
byIssuer[rec.URL] = provider
// Multiple records may share an issuer when each is scoped to a
// different account; STS picks the right one at validation time
// based on the role being assumed. See lookupOIDCProviderForAccount.
byIssuer[rec.URL] = append(byIssuer[rec.URL], sts.ScopedOIDCProvider{
AccountID: rec.AccountID,
Provider: provider,
})
}
m.stsService.SetIAMManagedOIDCProvidersByIssuer(byIssuer)
m.stsService.SetIAMManagedOIDCProviders(byIssuer)
return nil
}
@@ -698,6 +704,39 @@ func extractClientIDs(cfg map[string]interface{}) []string {
return nil
}
// extractStringList reads a JSON string array out of the provider's static
// config map and returns the non-empty entries. Returns nil when the key is
// missing, the value is the wrong shape, or every entry is empty.
func extractStringList(cfg map[string]interface{}, key string) []string {
if cfg == nil {
return nil
}
list, ok := cfg[key].([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(list))
for _, v := range list {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
}
// extractString reads a single string field from the provider's static
// config map; missing or non-string values produce "".
func extractString(cfg map[string]interface{}, key string) string {
if cfg == nil {
return ""
}
s, _ := cfg[key].(string)
return s
}
// getFilerAddress returns the current filer address using the provider function
func (m *IAMManager) getFilerAddress() string {
if m.filerAddressProvider != nil {
@@ -102,6 +102,71 @@ func TestStaticConfigSeedsProviderStore(t *testing.T) {
}
}
func TestStaticConfigMirrorsThumbprintsAndAdvancedFields(t *testing.T) {
// Static config sets thumbprints, AllowedPrincipalTagKeys, and PolicyClaim;
// the mirror into the IAM-managed store must carry all three. Without it,
// the next RefreshOIDCProvidersFromStore rebuilds a runtime provider that
// drops thumbprint pinning and silently disables claim-based policies and
// principal-tag passthrough — and because IAM-managed entries take
// precedence over the static-config map, the bootstrap provider gets
// shadowed by this weaker rebuild.
mgr := NewIAMManager()
cfg := &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"),
AccountId: "111122223333",
Providers: []*sts.ProviderConfig{
{
Name: "github-actions",
Type: sts.ProviderTypeOIDC,
Enabled: true,
Config: map[string]interface{}{
"issuer": "https://token.actions.githubusercontent.com",
"clientId": "sts.amazonaws.com",
"thumbprints": []interface{}{"6938fd4d98bab03faadb97b34396831e3780aea1"},
"allowedPrincipalTagKeys": []interface{}{"team", "env"},
"policyClaim": "policies",
},
},
},
},
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
Roles: &RoleStoreConfig{StoreType: "memory"},
}
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
t.Fatalf("Initialize: %v", err)
}
rec, err := mgr.GetOIDCProvider(context.Background(), "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com")
if err != nil {
t.Fatalf("GetOIDCProvider: %v", err)
}
if got, want := rec.Thumbprints, []string{"6938fd4d98bab03faadb97b34396831e3780aea1"}; !equalStrings(got, want) {
t.Fatalf("thumbprints mismatch: got %v want %v", got, want)
}
if got, want := rec.AllowedPrincipalTagKeys, []string{"team", "env"}; !equalStrings(got, want) {
t.Fatalf("allowedPrincipalTagKeys mismatch: got %v want %v", got, want)
}
if rec.PolicyClaim != "policies" {
t.Fatalf("policyClaim mismatch: got %q want %q", rec.PolicyClaim, "policies")
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestStoreNotConfiguredReturnsClearError(t *testing.T) {
mgr := NewIAMManager()
if _, err := mgr.GetOIDCProvider(context.Background(), "arn:..."); err == nil {
+105
View File
@@ -0,0 +1,105 @@
package sts
import (
"context"
"testing"
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
)
// stubIdentityProvider is the minimal IdentityProvider needed to drive
// lookupOIDCProviderForAccount; the lookup never calls Authenticate.
type stubIdentityProvider struct{ name string }
func (s *stubIdentityProvider) Name() string { return s.name }
func (s *stubIdentityProvider) Initialize(interface{}) error { return nil }
func (s *stubIdentityProvider) Authenticate(context.Context, string) (*providers.ExternalIdentity, error) {
return nil, nil
}
func (s *stubIdentityProvider) GetUserInfo(context.Context, string) (*providers.ExternalIdentity, error) {
return nil, nil
}
func (s *stubIdentityProvider) ValidateToken(context.Context, string) (*providers.TokenClaims, error) {
return nil, nil
}
func TestLookupOIDCProviderForAccountPrefersAccountMatch(t *testing.T) {
const issuer = "https://example.com"
accountA := &stubIdentityProvider{name: "A"}
accountB := &stubIdentityProvider{name: "B"}
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
issuer: {
{AccountID: "111111111111", Provider: accountA},
{AccountID: "222222222222", Provider: accountB},
},
})
got, ok := s.lookupOIDCProviderForAccount(issuer, "222222222222")
if !ok || got != accountB {
t.Fatalf("expected accountB provider for matching account, got %v ok=%v", got, ok)
}
got, ok = s.lookupOIDCProviderForAccount(issuer, "111111111111")
if !ok || got != accountA {
t.Fatalf("expected accountA provider for matching account, got %v ok=%v", got, ok)
}
}
func TestLookupOIDCProviderForAccountFallsBackToGlobal(t *testing.T) {
const issuer = "https://example.com"
global := &stubIdentityProvider{name: "global"}
accountA := &stubIdentityProvider{name: "A"}
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
issuer: {
{AccountID: "", Provider: global},
{AccountID: "111111111111", Provider: accountA},
},
})
// Account not represented in records → global match.
got, ok := s.lookupOIDCProviderForAccount(issuer, "999999999999")
if !ok || got != global {
t.Fatalf("expected global provider as fallback, got %v ok=%v", got, ok)
}
// Empty account hint → never picks an account-scoped record arbitrarily;
// only the global record is eligible.
got, ok = s.lookupOIDCProviderForAccount(issuer, "")
if !ok || got != global {
t.Fatalf("expected global provider when account unknown, got %v ok=%v", got, ok)
}
}
func TestLookupOIDCProviderForAccountSkipsAccountSpecificWhenAccountUnknown(t *testing.T) {
const issuer = "https://example.com"
accountA := &stubIdentityProvider{name: "A"}
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
issuer: {
{AccountID: "111111111111", Provider: accountA},
},
})
// No account hint AND no global record → must fall through (return false),
// not silently pick the account-A entry. Picking an arbitrary entry is the
// pre-fix bug that lets a token be validated by the wrong tenant's record.
if _, ok := s.lookupOIDCProviderForAccount(issuer, ""); ok {
t.Fatalf("expected no match when account unknown and only account-scoped records exist")
}
}
func TestLookupOIDCProviderForAccountFallsBackToStatic(t *testing.T) {
const issuer = "https://example.com"
static := &stubIdentityProvider{name: "static"}
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{issuer: static}}
s.SetIAMManagedOIDCProviders(nil)
got, ok := s.lookupOIDCProviderForAccount(issuer, "111111111111")
if !ok || got != static {
t.Fatalf("expected static-config provider as last resort, got %v ok=%v", got, ok)
}
}
+92 -32
View File
@@ -98,13 +98,28 @@ type STSService struct {
// iamManagedOIDCMu guards iamManagedOIDCByIssuer. The map is the live view
// of providers persisted in the IAM-managed OIDCProviderStore; it is
// atomically replaced by SetIAMManagedOIDCProvidersByIssuer whenever the
// store changes (either via a local IAM API call or a metadata-subscribe
// event from a peer). Lookups consult this map first and fall back to the
// atomically replaced by SetIAMManagedOIDCProviders whenever the store
// changes (either via a local IAM API call or a metadata-subscribe event
// from a peer). Lookups consult this map first and fall back to the
// static-config issuerToProvider so admin-managed entries always take
// precedence over the bootstrap config.
iamManagedOIDCMu sync.RWMutex
iamManagedOIDCByIssuer map[string]providers.IdentityProvider
//
// The slice value lets multiple records share an issuer when each is
// scoped to a different account; lookup picks the entry whose AccountID
// matches the role being assumed (or the global, AccountID="" entry as a
// fallback). Without this, two accounts' records for the same issuer
// would race for one map slot and a token could be validated by a
// provider that wasn't scoped to the role's account.
iamManagedOIDCMu sync.RWMutex
iamManagedOIDCByIssuer map[string][]ScopedOIDCProvider
}
// ScopedOIDCProvider pairs an OIDC IdentityProvider with the account it is
// scoped to. AccountID="" means the provider is global (usable from any
// account).
type ScopedOIDCProvider struct {
AccountID string
Provider providers.IdentityProvider
}
// GetTokenGenerator returns the token generator used by the STS service.
@@ -450,36 +465,65 @@ func (s *STSService) GetProviders() map[string]providers.IdentityProvider {
return s.providers
}
// SetIAMManagedOIDCProvidersByIssuer atomically replaces the IAM-managed
// OIDC provider map. Pass nil or an empty map to clear all managed entries.
// The caller passes a fully-built map keyed by issuer URL; the STS service
// copies the reference and serves AssumeRoleWithWebIdentity lookups from it
// in preference to the static-config issuerToProvider map.
func (s *STSService) SetIAMManagedOIDCProvidersByIssuer(byIssuer map[string]providers.IdentityProvider) {
// SetIAMManagedOIDCProviders atomically replaces the IAM-managed OIDC
// provider map. Pass nil or an empty map to clear all managed entries. The
// caller passes a fully-built map keyed by issuer URL; the slice value lets
// per-account records coexist under the same issuer.
func (s *STSService) SetIAMManagedOIDCProviders(byIssuer map[string][]ScopedOIDCProvider) {
// Defensively copy so callers can keep mutating their map without affecting
// in-flight lookups. A nil input becomes an empty map for cheap reads.
cp := make(map[string]providers.IdentityProvider, len(byIssuer))
for k, v := range byIssuer {
if k == "" || v == nil {
cp := make(map[string][]ScopedOIDCProvider, len(byIssuer))
for issuer, scoped := range byIssuer {
if issuer == "" {
continue
}
cp[k] = v
entries := make([]ScopedOIDCProvider, 0, len(scoped))
for _, sp := range scoped {
if sp.Provider == nil {
continue
}
entries = append(entries, sp)
}
if len(entries) > 0 {
cp[issuer] = entries
}
}
s.iamManagedOIDCMu.Lock()
s.iamManagedOIDCByIssuer = cp
s.iamManagedOIDCMu.Unlock()
}
// lookupOIDCProviderByIssuer returns the provider that should validate tokens
// from `issuer`, consulting the IAM-managed map first and falling back to the
// static-config map. Returns ok=false when no provider is registered.
func (s *STSService) lookupOIDCProviderByIssuer(issuer string) (providers.IdentityProvider, bool) {
// lookupOIDCProviderForAccount returns the provider that should validate
// tokens from `issuer` when the caller is assuming a role in `accountID`.
// Selection order:
// 1. IAM-managed record exactly scoped to accountID (when accountID != "");
// 2. IAM-managed record with empty AccountID (global);
// 3. static-config issuerToProvider (legacy path; account-agnostic).
//
// Without the (issuer, account) key, two records for the same issuer (e.g.
// account A with clientIDs=[a] and account B with clientIDs=[b]) would race
// for one map slot, and a token destined for account B could be validated by
// account A's record. The role-account check in
// IAMManager.enforceProviderAccountScope blocks the cross-account assumption
// itself, but the validation must use the right record's clientIDs and
// thumbprints in the first place.
func (s *STSService) lookupOIDCProviderForAccount(issuer, accountID string) (providers.IdentityProvider, bool) {
s.iamManagedOIDCMu.RLock()
if p, ok := s.iamManagedOIDCByIssuer[issuer]; ok {
s.iamManagedOIDCMu.RUnlock()
return p, true
scoped := s.iamManagedOIDCByIssuer[issuer]
var globalMatch providers.IdentityProvider
for _, sp := range scoped {
if accountID != "" && sp.AccountID == accountID {
s.iamManagedOIDCMu.RUnlock()
return sp.Provider, true
}
if sp.AccountID == "" && globalMatch == nil {
globalMatch = sp.Provider
}
}
s.iamManagedOIDCMu.RUnlock()
if globalMatch != nil {
return globalMatch, true
}
p, ok := s.issuerToProvider[issuer]
return p, ok
}
@@ -514,8 +558,13 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
sessionPolicy = normalized
}
// 1. Validate the web identity token with appropriate provider
externalIdentity, provider, err := s.validateWebIdentityToken(ctx, request.WebIdentityToken)
// 1. Validate the web identity token with appropriate provider. The role
// ARN's account scopes which IAM-managed record may validate the token —
// see lookupOIDCProviderForAccount. ParseRoleARN returns "" when the
// caller passed a legacy or claim-based ARN, in which case lookup falls
// back to a global (account-less) record only.
roleAccountID := utils.ParseRoleARN(request.RoleArn).AccountID
externalIdentity, provider, err := s.validateWebIdentityToken(ctx, request.WebIdentityToken, roleAccountID)
if err != nil {
return nil, fmt.Errorf("failed to validate web identity token: %w", err)
}
@@ -799,7 +848,7 @@ func (s *STSService) validateAssumeRoleWithWebIdentityRequest(request *AssumeRol
// validateWebIdentityToken validates the web identity token with strict issuer-to-provider mapping
// SECURITY: JWT tokens with a specific issuer claim MUST only be validated by the provider for that issuer
// SECURITY: This method only accepts JWT tokens. Non-JWT authentication must use AssumeRoleWithCredentials with explicit ProviderName.
func (s *STSService) validateWebIdentityToken(ctx context.Context, token string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
func (s *STSService) validateWebIdentityToken(ctx context.Context, token, roleAccountID string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
// Try to extract issuer from JWT token for strict validation
issuer, err := s.extractIssuerFromJWT(token)
if err != nil {
@@ -810,11 +859,12 @@ func (s *STSService) validateWebIdentityToken(ctx context.Context, token string)
return nil, nil, fmt.Errorf("web identity token must be a valid JWT token: %w", err)
}
// Look up the specific provider for this issuer. IAM-managed records
// (admin-controlled, mutable at runtime) take precedence over the
// static-config map so an operator's CreateOpenIDConnectProvider call
// can shadow a bootstrap entry without requiring a restart.
provider, exists := s.lookupOIDCProviderByIssuer(issuer)
// Look up the specific provider for this issuer, scoped to the role's
// account when known. IAM-managed records (admin-controlled, mutable at
// runtime) take precedence over the static-config map so an operator's
// CreateOpenIDConnectProvider call can shadow a bootstrap entry without
// requiring a restart.
provider, exists := s.lookupOIDCProviderForAccount(issuer, roleAccountID)
if !exists {
// SECURITY: If no provider is registered for this issuer, fail immediately
// This prevents JWT tokens from being validated by unintended providers
@@ -849,9 +899,19 @@ func (s *STSService) validateWebIdentityToken(ctx context.Context, token string)
}
// ValidateWebIdentityToken is a public method that exposes secure token validation for external use
// This method uses issuer-based lookup to select the correct provider, ensuring security and efficiency
// This method uses issuer-based lookup to select the correct provider, ensuring security and efficiency.
// External callers without role context get the account-agnostic lookup (global IAM-managed records
// only, then static-config); call ValidateWebIdentityTokenForAccount when the assumed-role account
// is known.
func (s *STSService) ValidateWebIdentityToken(ctx context.Context, token string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
return s.validateWebIdentityToken(ctx, token)
return s.validateWebIdentityToken(ctx, token, "")
}
// ValidateWebIdentityTokenForAccount mirrors ValidateWebIdentityToken but
// scopes the IAM-managed provider lookup to roleAccountID. Pass "" for
// callers that don't yet know the account (e.g. claim-based mode).
func (s *STSService) ValidateWebIdentityTokenForAccount(ctx context.Context, token, roleAccountID string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
return s.validateWebIdentityToken(ctx, token, roleAccountID)
}
// extractIssuerFromJWT extracts the issuer (iss) claim from a JWT token without verification
+28 -7
View File
@@ -161,6 +161,22 @@ func (h *STSHandlers) getAccountID() string {
return defaultAccountID
}
// assumeRoleWithWebIdentity dispatches the request through the IAMManager
// wrapper when one is wired so its cross-account provider scope check and
// per-role MaxSessionDuration clamp run for the public AWS-SDK path. Without
// this dispatch, both checks are silently skipped because they live on the
// IAMManager, not on the bare STS service.
func (h *STSHandlers) assumeRoleWithWebIdentity(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) (*sts.AssumeRoleResponse, error) {
if h.iam != nil && h.iam.iamIntegration != nil {
if provider, ok := h.iam.iamIntegration.(IAMManagerProvider); ok {
if mgr := provider.GetIAMManager(); mgr != nil {
return mgr.AssumeRoleWithWebIdentity(ctx, request)
}
}
}
return h.stsService.AssumeRoleWithWebIdentity(ctx, request)
}
// HandleSTSRequest is the main entry point for STS requests
// It routes requests based on the Action parameter
func (h *STSHandlers) HandleSTSRequest(w http.ResponseWriter, r *http.Request) {
@@ -213,11 +229,13 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
return
}
if roleArn == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("RoleArn is required"))
return
}
// RoleArn is intentionally optional here: claim-based policy mode
// (Phase 3b) advertises that callers MAY omit RoleArn so the STS
// service derives the assumed-role ARN from the configured policy
// claim. The bare-STS path validates this — when the IDP isn't
// configured for claim-based mode (or fails to emit policies) it
// returns a precise error that this handler maps to the right STS
// error code below.
if errCode, err := validateRoleSessionName(roleSessionName); err != nil {
h.writeSTSErrorResponse(w, r, errCode, err)
@@ -259,8 +277,11 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
Policy: sessionPolicyPtr,
}
// Call STS service
response, err := h.stsService.AssumeRoleWithWebIdentity(ctx, request)
// Prefer the IAMManager wrapper so the cross-account provider scope
// (enforceProviderAccountScope) and per-role MaxSessionDuration clamp
// run for SDK callers too. Falling back to the bare STS service keeps
// embedded test setups (no IAM integration wired) working.
response, err := h.assumeRoleWithWebIdentity(ctx, request)
if err != nil {
glog.V(2).Infof("AssumeRoleWithWebIdentity failed: %v", err)
@@ -0,0 +1,49 @@
package s3api
import (
"context"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
)
// TestAssumeRoleWithWebIdentity_DispatchesThroughIAMManager confirms the
// public STS HTTP path goes through IAMManager.AssumeRoleWithWebIdentity (and
// thereby its enforceProviderAccountScope check and MaxSessionDuration
// clamp) instead of bypassing to the bare STS service. The two paths surface
// different errors when their underlying service is uninitialized, which is
// the cheapest behavioural signal that doesn't require a full OIDC stack.
func TestAssumeRoleWithWebIdentity_DispatchesThroughIAMManager(t *testing.T) {
req := &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::111111111111:role/Test",
WebIdentityToken: "ignored",
RoleSessionName: "session",
}
t.Run("with IAMManager wired", func(t *testing.T) {
mgr := integration.NewIAMManager() // not initialized on purpose
h := &STSHandlers{
stsService: nil, // intentionally nil; the wrapper must run first
iam: &IdentityAccessManagement{
iamIntegration: NewS3IAMIntegration(mgr, ""),
},
}
_, err := h.assumeRoleWithWebIdentity(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "IAM manager not initialized") {
t.Fatalf("expected IAMManager wrapper to handle the call; got err=%v", err)
}
})
t.Run("without IAM integration", func(t *testing.T) {
h := &STSHandlers{
stsService: sts.NewSTSService(), // not initialized
iam: &IdentityAccessManagement{},
}
_, err := h.assumeRoleWithWebIdentity(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "STS service not initialized") {
t.Fatalf("expected fallback to bare STS service; got err=%v", err)
}
})
}
@@ -0,0 +1,42 @@
package s3api
import (
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestAssumeRoleWithWebIdentity_AllowsEmptyRoleArn confirms the HTTP handler
// no longer rejects empty RoleArn before STS sees the request. Phase 3b's
// claim-based mode advertises that callers MAY omit RoleArn so the policy
// claim derives the assumed-role ARN; the handler must let that flow
// through. STS-layer failures (invalid token, claim-mode not configured) are
// surfaced separately and don't read "RoleArn is required".
func TestAssumeRoleWithWebIdentity_AllowsEmptyRoleArn(t *testing.T) {
stsService, _ := setupTestSTSService(t)
h := &STSHandlers{
stsService: stsService,
iam: &IdentityAccessManagement{},
}
form := url.Values{}
form.Set("Action", "AssumeRoleWithWebIdentity")
form.Set("WebIdentityToken", "not-a-real-jwt")
form.Set("RoleSessionName", "session-1")
// RoleArn intentionally omitted.
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if err := req.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
rr := httptest.NewRecorder()
h.handleAssumeRoleWithWebIdentity(rr, req)
body := rr.Body.String()
if strings.Contains(body, "RoleArn is required") {
t.Fatalf("HTTP handler rejected empty RoleArn pre-STS; body=%q", body)
}
}