diff --git a/weed/iam/integration/account_scope_test.go b/weed/iam/integration/account_scope_test.go new file mode 100644 index 0000000000..8195a304ba --- /dev/null +++ b/weed/iam/integration/account_scope_test.go @@ -0,0 +1,72 @@ +package integration + +import ( + "context" + "testing" +) + +func TestGetProviderByIssuerAndAccount(t *testing.T) { + ctx := context.Background() + store := NewMemoryOIDCProviderStore() + + // Two providers for the same issuer, scoped to different accounts. + must := func(rec *OIDCProviderRecord) { + if err := store.StoreProvider(ctx, "", rec); err != nil { + t.Fatalf("store: %v", err) + } + } + must(&OIDCProviderRecord{ + AccountID: "111", + ARN: "arn:aws:iam::111:oidc-provider/idp.example", + URL: "https://idp.example", + ClientIDs: []string{"a"}, + }) + must(&OIDCProviderRecord{ + AccountID: "222", + ARN: "arn:aws:iam::222:oidc-provider/idp.example", + URL: "https://idp.example", + ClientIDs: []string{"b"}, + }) + + t.Run("matching account returns scoped record", func(t *testing.T) { + rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "111") + if err != nil { + t.Fatalf("err: %v", err) + } + if rec.AccountID != "111" { + t.Fatalf("unexpected record: %+v", rec) + } + }) + + t.Run("unknown account is rejected", func(t *testing.T) { + if _, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "999"); err == nil { + t.Fatal("expected error for cross-account use") + } + }) + + t.Run("global provider satisfies any account", func(t *testing.T) { + must(&OIDCProviderRecord{ + AccountID: "", + ARN: "arn:aws:iam:::oidc-provider/global.example", + URL: "https://global.example", + ClientIDs: []string{"x"}, + }) + rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://global.example", "anything") + if err != nil { + t.Fatalf("err: %v", err) + } + if rec.AccountID != "" { + t.Fatalf("expected global record, got AccountID=%q", rec.AccountID) + } + }) + + t.Run("empty caller account accepts any record", func(t *testing.T) { + rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "") + if err != nil { + t.Fatalf("err: %v", err) + } + if rec == nil { + t.Fatal("expected a record") + } + }) +} diff --git a/weed/iam/integration/iam_manager.go b/weed/iam/integration/iam_manager.go index 95d6c440bf..9ce4701bc8 100644 --- a/weed/iam/integration/iam_manager.go +++ b/weed/iam/integration/iam_manager.go @@ -744,6 +744,14 @@ func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts return nil, fmt.Errorf("IAM manager not initialized") } + // Claim-based mode bypasses role lookup and trust policy entirely; the + // STS service handles the policy resolution from the JWT itself. Account + // scoping still applies but at the provider-resolution layer in Phase 3c + // (we'll plug that in once a multi-account assume path lands). + if sts.IsClaimBasedPolicyRoleArn(request.RoleArn) { + return m.stsService.AssumeRoleWithWebIdentity(ctx, request) + } + // Extract role name from ARN roleName := utils.ExtractRoleNameFromArn(request.RoleArn) @@ -753,6 +761,14 @@ func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts return nil, fmt.Errorf("role not found: %s", roleName) } + // Account scoping: when the role lives in account A, the OIDC provider + // validating the token must be either global (AccountID="") or also live + // in account A. Skip when we can't resolve account context; fall through + // to the existing trust-policy enforcement. + if err := m.enforceProviderAccountScope(ctx, request); err != nil { + return nil, err + } + // Validate trust policy before allowing STS to assume the role if err := m.validateTrustPolicyForWebIdentity(ctx, roleDef, request.WebIdentityToken, request.DurationSeconds); err != nil { return nil, fmt.Errorf("trust policy validation failed: %w", err) @@ -767,6 +783,63 @@ func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts return m.stsService.AssumeRoleWithWebIdentity(ctx, request) } +// enforceProviderAccountScope checks that the OIDC provider matching the +// token's issuer is registered in either the role's account or as a global +// (account-less) provider. Returns nil when no provider store is configured +// (preserves the static-config-only path) or when the issuer is not known to +// the store (the existing STS-layer issuer→provider map handles that case +// during validation). +func (m *IAMManager) enforceProviderAccountScope(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) error { + if m.oidcProviderStore == nil { + return nil + } + roleAccount := utils.ParseRoleARN(request.RoleArn).AccountID + if roleAccount == "" { + // Legacy ARN form (no account): nothing to enforce. + return nil + } + issuer, err := extractIssuerFromJWT(request.WebIdentityToken) + if err != nil { + return nil // signature validation will reject, no need to fail twice + } + // Look for a provider that is either global or scoped to the role's + // account. Multiple providers may share an issuer (e.g. one global plus + // one per tenant), and GetProviderByIssuer returns the first match + // arbitrarily — using it here would falsely reject a valid request + // whenever the wrong record happens to come back first. + if _, err := m.oidcProviderStore.GetProviderByIssuerAndAccount(ctx, m.getFilerAddress(), issuer, roleAccount); err == nil { + return nil + } + // No allowed match. Distinguish "issuer entirely unknown" (let the STS + // layer reject with the existing not-registered error) from "issuer is + // registered but only in a different account" (surface a precise error + // so the operator knows the call was cross-account). + if other, err := m.oidcProviderStore.GetProviderByIssuer(ctx, m.getFilerAddress(), issuer); err == nil { + return fmt.Errorf("OIDC provider for issuer %s is registered in account %s; cannot be used to assume a role in account %s", issuer, other.AccountID, roleAccount) + } + return nil +} + +// extractIssuerFromJWT returns the iss claim of a JWT without verifying its +// signature. Safe here because the caller still goes through full signature +// + issuer validation in the STS service; this is purely for routing. +func extractIssuerFromJWT(token string) (string, error) { + parser := new(jwt.Parser) + parsed, _, err := parser.ParseUnverified(token, jwt.MapClaims{}) + if err != nil { + return "", err + } + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + return "", fmt.Errorf("invalid claims") + } + iss, _ := claims["iss"].(string) + if iss == "" { + return "", fmt.Errorf("token has no iss claim") + } + return iss, nil +} + // capDurationByRole returns the requested duration clamped to the role's // MaxSessionDuration. A nil requested duration is left nil so the STS // service's calculateSessionDuration applies the global default (typically diff --git a/weed/iam/integration/oidc_provider_store.go b/weed/iam/integration/oidc_provider_store.go index 5f4e1a5c13..1a139d78f9 100644 --- a/weed/iam/integration/oidc_provider_store.go +++ b/weed/iam/integration/oidc_provider_store.go @@ -81,6 +81,7 @@ type OIDCProviderStore interface { StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error) GetProviderByIssuer(ctx context.Context, filerAddress string, issuer string) (*OIDCProviderRecord, error) + GetProviderByIssuerAndAccount(ctx context.Context, filerAddress string, issuer, accountID string) (*OIDCProviderRecord, error) ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error) DeleteProvider(ctx context.Context, filerAddress string, arn string) error } @@ -137,6 +138,29 @@ func (m *MemoryOIDCProviderStore) GetProviderByIssuer(ctx context.Context, _ str return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer) } +// GetProviderByIssuerAndAccount returns the record matching `issuer` whose +// AccountID is empty (global) or equals `accountID`. AWS-style account +// scoping: cross-account references must go through a shared/global +// provider, which is why an empty AccountID is treated as a wildcard. +// +// `accountID` may itself be empty when the caller has no specific account +// context (e.g. claim-based mode); in that case any registered provider +// matching the issuer is acceptable. +func (m *MemoryOIDCProviderStore) GetProviderByIssuerAndAccount(ctx context.Context, _ string, issuer, accountID string) (*OIDCProviderRecord, error) { + want := normalizeIssuer(issuer) + m.mu.RLock() + defer m.mu.RUnlock() + for _, rec := range m.providers { + if normalizeIssuer(rec.URL) != want { + continue + } + if rec.AccountID == "" || accountID == "" || rec.AccountID == accountID { + return copyOIDCProviderRecord(rec), nil + } + } + return nil, fmt.Errorf("no OIDC provider registered for issuer %s in account %s", issuer, accountID) +} + // ListProviders returns every record sorted by ARN for stable output. func (m *MemoryOIDCProviderStore) ListProviders(ctx context.Context, _ string) ([]*OIDCProviderRecord, error) { m.mu.RLock() @@ -283,6 +307,25 @@ func (f *FilerOIDCProviderStore) GetProviderByIssuer(ctx context.Context, filerA return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer) } +// GetProviderByIssuerAndAccount filters by AccountID on top of the issuer scan. +// See the MemoryOIDCProviderStore equivalent for semantics. +func (f *FilerOIDCProviderStore) GetProviderByIssuerAndAccount(ctx context.Context, filerAddress string, issuer, accountID string) (*OIDCProviderRecord, error) { + records, err := f.ListProviders(ctx, filerAddress) + if err != nil { + return nil, err + } + want := normalizeIssuer(issuer) + for _, rec := range records { + if normalizeIssuer(rec.URL) != want { + continue + } + if rec.AccountID == "" || accountID == "" || rec.AccountID == accountID { + return rec, nil + } + } + return nil, fmt.Errorf("no OIDC provider registered for issuer %s in account %s", issuer, accountID) +} + // ListProviders enumerates every record in the directory. func (f *FilerOIDCProviderStore) ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error) { filerAddress = f.resolveFilerAddress(filerAddress)