feat(iam): OIDC provider store + read-only IAM API (Phase 2a) (#9319)

* feat(iam): STS web-identity AWS-fidelity polish

- OIDC discovery via .well-known/openid-configuration; falls back to
  /.well-known/jwks.json when discovery is absent. Reject discovery docs
  whose issuer claim does not match the configured issuer to defend
  against issuer-substitution.
- ComputeParentUser derives a stable per-identity hash from (sub, iss).
  Surface as aws:userid in the request context and as a parent_user
  claim in the session JWT so per-user state survives token rotation.
- Per-role MaxSessionDuration (3600..43200) clamps requested
  DurationSeconds before the STS service applies its own caps.
- Tighten RoleSessionName to the AWS contract: 2..64 chars from
  [\w+=,.@-].
- Populate PackedPolicySize in AssumeRole / AssumeRoleWithWebIdentity /
  AssumeRoleWithLDAPIdentity responses as a percentage of the 2048-byte
  inline session policy budget.

* fix(iam): leave omitted DurationSeconds nil so STS default applies

capDurationByRole was substituting the role's MaxSessionDuration
when the caller omitted DurationSeconds entirely. AWS returns the
configured default (typically 1 hour) in that case, not the role's
upper bound — a 12h MaxSessionDuration shouldn't silently make every
no-duration assume-role mint a 12h session.

Return nil when requested is nil; let the downstream
calculateSessionDuration in the STS service apply its TokenDuration
default. The role-max upper bound still clamps when the request
arrives with a concrete value above the cap.

Addresses gemini high-priority review on PR #9318.

* fix(iam): synchronize OIDCProvider JWKS cache fields

jwksCache, jwksFetchedAt, resolvedJWKSUri, and discoveryFailed are
mutated lazily on the first token-validate call and refreshed
afterwards on TTL expiry. Multiple S3 requests can land here in
parallel, so the writes were racing against subsequent reads on
every other goroutine. resolvedJWKSUri/discoveryFailed inherited
the same un-protected pattern when discovery shipped.

Add sync.RWMutex; getPublicKey takes the read lock for the
common cache-hit path and promotes to the write lock for misses
+ refreshes. fetchJWKSLocked / resolveJWKSUriLocked assume the
write lock is held by the caller; fetchJWKS keeps the
test-friendly entry point that acquires the lock itself.

Addresses gemini high-priority review on PR #9318.

* fix(iam): trim trailing slash + retry discovery after transient failure

Two OIDC discovery edge cases reviewers flagged:

1. Issuer comparison was sensitive to trailing slashes. resolveJWKSUri
   trims them when building the discovery URL, but the doc.Issuer ↔
   p.config.Issuer check did not, so an IDP whose issuer claim drops or
   adds the slash relative to the configured value would be falsely
   rejected. Trim a single trailing slash on each side before comparing.

2. discoveryFailed flipped to true on any error and stayed there for the
   process lifetime. A transient 5xx at startup permanently locked the
   provider into the /.well-known/jwks.json fallback. Reset the flag at
   the top of fetchJWKSLocked when no URI has been cached yet, so each
   JWKS refresh (typically once per TTL = 1h) reattempts discovery.
   Successful discovery remains cached via resolvedJWKSUri so we don't
   pay the discovery RTT on every refresh.

Addresses gemini security-medium + medium reviews on PR #9318.

* fix(iam): require non-empty issuer in OIDC discovery doc

The previous "doc.Issuer != "" && ..." guard let a discovery document
that omitted the issuer field bypass the issuer-mismatch check
entirely, letting the doc steer fetchJWKS at any URL it provided.
OIDC Discovery 1.0 §3 mandates the issuer field; treat missing as a
hard failure same as mismatched. Trailing-slash equivalence still
applies.

Adds TestDiscoveryRejectsMissingIssuer alongside the existing
TestDiscoveryRejectsIssuerMismatch via a new omitDiscoveryIssuer
toggle on fakeIDP.

* feat(iam): OIDC provider store + read-only IAM API

Add OIDCProviderRecord — the persisted, IAM-managed view of an OIDC
identity provider — and an OIDCProviderStore interface with memory and
filer implementations mirroring the existing role-store pattern.

The store is hydrated at boot from the static STS.Providers list so the
new IAM API surfaces the same set the STS service already validates
against. Two read-only actions land now:

- ListOpenIDConnectProviders -> ARN-only list, AWS-shape XML.
- GetOpenIDConnectProvider   -> URL, ClientIDList, ThumbprintList,
                                Tags, CreateDate.

Mutations (Create/Delete/Add-Remove ClientID/Update Thumbprint), multiple
client_ids per provider, and TLS thumbprint pinning come in Phase 2b.

* fix(iam): preserve CreatedAt across boots + paginate ListProviders

Two medium-priority issues gemini flagged on the read-only IAM API:

1. The static-config bootstrap was setting CreatedAt = time.Now() on
   every server start, so the IAM GetOpenIDConnectProvider response's
   CreateDate shifted on each restart even when backed by a persistent
   store. Look up the existing record via GetProviderByARN first and
   preserve its CreatedAt; only the UpdatedAt advances.

2. FilerOIDCProviderStore.ListProviders had a hardcoded Limit: 1000
   that silently truncated above that. Stream-paginate via
   StartFromFileName, returning io.EOF naturally and surfacing all
   other errors instead of swallowing them.

Addresses two gemini medium reviews on PR #9319.
This commit is contained in:
Chris Lu
2026-05-04 22:15:03 -07:00
committed by GitHub
parent d951a8df5a
commit 4ded97a321
8 changed files with 1192 additions and 1 deletions
+145
View File
@@ -7,8 +7,10 @@ import (
"fmt"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
@@ -27,12 +29,44 @@ type IAMManager struct {
policyEngine *policy.PolicyEngine
roleStore RoleStore
userStore UserStore
oidcProviderStore OIDCProviderStore
filerAddressProvider func() string // Function to get current filer address
initialized bool
runtimePolicyMu sync.Mutex
runtimePolicyNames map[string]struct{}
}
// SetOIDCProviderStore configures the IAM-managed OIDC provider store. When
// nil, OIDC provider IAM actions return ServiceNotReady. The store is the
// source of truth for AssumeRoleWithWebIdentity provider resolution once
// Phase 2b lands; in Phase 2a it is read-only and populated from static
// configuration at boot.
func (m *IAMManager) SetOIDCProviderStore(store OIDCProviderStore) {
m.oidcProviderStore = store
}
// GetOIDCProviderStore returns the configured store (may be nil).
func (m *IAMManager) GetOIDCProviderStore() OIDCProviderStore {
return m.oidcProviderStore
}
// GetOIDCProvider returns the record for the given ARN, or an error if the
// store is not configured or the record is missing.
func (m *IAMManager) GetOIDCProvider(ctx context.Context, arn string) (*OIDCProviderRecord, error) {
if m.oidcProviderStore == nil {
return nil, fmt.Errorf("OIDC provider store not configured")
}
return m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
}
// ListOIDCProviders enumerates all configured OIDC providers.
func (m *IAMManager) ListOIDCProviders(ctx context.Context) ([]*OIDCProviderRecord, error) {
if m.oidcProviderStore == nil {
return nil, fmt.Errorf("OIDC provider store not configured")
}
return m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
}
// IAMConfig holds configuration for all IAM components
type IAMConfig struct {
// STS service configuration
@@ -43,6 +77,17 @@ type IAMConfig struct {
// Role store configuration
Roles *RoleStoreConfig `json:"roleStore"`
// OIDCProviders configures the IAM-managed OIDC provider store. Optional;
// if absent the manager defaults to an in-memory store hydrated from
// STS.Providers at boot.
OIDCProviders *OIDCProviderStoreConfig `json:"oidcProviderStore,omitempty"`
}
// OIDCProviderStoreConfig holds OIDC provider store configuration.
type OIDCProviderStoreConfig struct {
StoreType string `json:"storeType"` // memory, filer
StoreConfig map[string]interface{} `json:"storeConfig,omitempty"`
}
// RoleStoreConfig holds role store configuration
@@ -196,10 +241,110 @@ func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() s
}
m.roleStore = roleStore
// Initialize OIDC provider store and hydrate from static configuration so
// the read-only IAM API can return the same providers the STS service
// already accepts. Mutations will land in Phase 2b.
if err := m.initOIDCProviderStore(config); err != nil {
return fmt.Errorf("failed to initialize OIDC provider store: %w", err)
}
m.initialized = true
return nil
}
// initOIDCProviderStore creates the OIDC provider store and seeds it from the
// static STS provider configuration. The static path remains the bootstrap
// source: each enabled OIDC entry under STS.Providers is mirrored as an
// OIDCProviderRecord so the IAM API surfaces the same set the STS service
// validates against.
func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
store, err := m.createOIDCProviderStore(config.OIDCProviders)
if err != nil {
return err
}
m.oidcProviderStore = store
if config.STS == nil {
return nil
}
for _, pc := range config.STS.Providers {
if pc == nil || !pc.Enabled || pc.Type != sts.ProviderTypeOIDC {
continue
}
issuer, _ := pc.Config["issuer"].(string)
if issuer == "" {
glog.Warningf("OIDC provider %s in static config has empty issuer; skipping mirror to store", pc.Name)
continue
}
accountID := ""
if config.STS != nil {
accountID = config.STS.AccountId
}
arn, err := DeriveOIDCProviderARN(accountID, issuer)
if err != nil {
glog.Warningf("derive ARN for static OIDC provider %s: %v", pc.Name, err)
continue
}
clientIDs := extractClientIDs(pc.Config)
ctx := context.Background()
// Preserve CreatedAt across reboots when a persistent store already
// has this provider — IAM's GetOpenIDConnectProvider response
// shouldn't shift its CreateDate every time the server restarts.
now := time.Now().UTC()
createdAt := now
if existing, err := store.GetProviderByARN(ctx, m.getFilerAddress(), arn); err == nil && existing != nil && !existing.CreatedAt.IsZero() {
createdAt = existing.CreatedAt
}
rec := &OIDCProviderRecord{
AccountID: accountID,
ARN: arn,
URL: issuer,
ClientIDs: clientIDs,
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)
}
}
return nil
}
// createOIDCProviderStore selects an OIDCProviderStore implementation. Defaults
// to memory; "filer" requires a filerAddressProvider to be configured.
func (m *IAMManager) createOIDCProviderStore(cfg *OIDCProviderStoreConfig) (OIDCProviderStore, error) {
if cfg == nil || cfg.StoreType == "" || cfg.StoreType == "memory" {
return NewMemoryOIDCProviderStore(), nil
}
if cfg.StoreType == "filer" {
return NewFilerOIDCProviderStore(cfg.StoreConfig, m.filerAddressProvider), nil
}
return nil, fmt.Errorf("unsupported OIDC provider store type: %s", cfg.StoreType)
}
// extractClientIDs reads a single clientId or a clientIds list from the
// provider's static config map. Mirrors the OIDCConfig schema.
func extractClientIDs(cfg map[string]interface{}) []string {
if cfg == nil {
return nil
}
if list, ok := cfg["clientIds"].([]interface{}); ok {
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 out
}
}
if id, ok := cfg["clientId"].(string); ok && id != "" {
return []string{id}
}
return nil
}
// getFilerAddress returns the current filer address using the provider function
func (m *IAMManager) getFilerAddress() string {
if m.filerAddressProvider != nil {
@@ -0,0 +1,113 @@
package integration
import (
"context"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
)
func TestStaticConfigSeedsProviderStore(t *testing.T) {
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: "google",
Type: sts.ProviderTypeOIDC,
Enabled: true,
Config: map[string]interface{}{
"issuer": "https://accounts.google.com",
"clientId": "1234.apps.googleusercontent.com",
},
},
{
Name: "github-actions",
Type: sts.ProviderTypeOIDC,
Enabled: true,
Config: map[string]interface{}{
"issuer": "https://token.actions.githubusercontent.com",
"clientId": "sts.amazonaws.com",
},
},
{
Name: "disabled-google",
Type: sts.ProviderTypeOIDC,
Enabled: false,
Config: map[string]interface{}{
"issuer": "https://accounts.disabled.example",
"clientId": "x",
},
},
},
},
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)
}
store := mgr.GetOIDCProviderStore()
if store == nil {
t.Fatal("expected store to be initialized")
}
got, err := mgr.ListOIDCProviders(context.Background())
if err != nil {
t.Fatalf("ListOIDCProviders: %v", err)
}
// Disabled providers must not be mirrored.
if len(got) != 2 {
t.Fatalf("expected 2 providers, got %d (%v)", len(got), got)
}
byArn := map[string]*OIDCProviderRecord{}
for _, r := range got {
byArn[r.ARN] = r
}
googleARN := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
g, ok := byArn[googleARN]
if !ok {
t.Fatalf("missing google ARN; have %v", byArn)
}
if len(g.ClientIDs) != 1 || g.ClientIDs[0] != "1234.apps.googleusercontent.com" {
t.Fatalf("google clientIds wrong: %v", g.ClientIDs)
}
ghARN := "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
gh, ok := byArn[ghARN]
if !ok {
t.Fatalf("missing github-actions ARN; have %v", byArn)
}
if len(gh.ClientIDs) != 1 || gh.ClientIDs[0] != "sts.amazonaws.com" {
t.Fatalf("github clientIds wrong: %v", gh.ClientIDs)
}
// Spot-check the IAM read path returns the same record.
rec, err := mgr.GetOIDCProvider(context.Background(), googleARN)
if err != nil {
t.Fatalf("GetOIDCProvider: %v", err)
}
if rec.URL != "https://accounts.google.com" {
t.Fatalf("URL mismatch: %s", rec.URL)
}
}
func TestStoreNotConfiguredReturnsClearError(t *testing.T) {
mgr := NewIAMManager()
if _, err := mgr.GetOIDCProvider(context.Background(), "arn:..."); err == nil {
t.Fatal("expected error when store not configured")
}
if _, err := mgr.ListOIDCProviders(context.Background()); err == nil {
t.Fatal("expected error when store not configured")
}
}
+421
View File
@@ -0,0 +1,421 @@
package integration
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"google.golang.org/grpc"
)
// OIDCProviderRecord is the persisted, IAM-managed view of an OIDC identity
// provider. It is the source of truth consulted at AssumeRoleWithWebIdentity
// time. Static configuration entries are loaded into this same store at
// startup so the resolution path is uniform.
type OIDCProviderRecord struct {
// AccountID scopes the record. Empty means "global" — usable from any
// account. Non-empty records are only resolvable when the assuming
// caller's RoleArn lives in the same account. Phase 2a leaves this
// field unenforced; Phase 3c lights up the cross-account check.
AccountID string `json:"accountId,omitempty"`
// ARN is the canonical IAM identifier for the provider:
// arn:aws:iam::<account>:oidc-provider/<host>/<path>.
ARN string `json:"arn"`
// URL is the issuer URL (no trailing slash), e.g.
// https://token.actions.githubusercontent.com.
URL string `json:"url"`
// ClientIDs are the audiences AWS calls "client IDs". A token's `aud` or
// `azp` must match one of these for the token to be accepted.
ClientIDs []string `json:"clientIds,omitempty"`
// Thumbprints are SHA-1 hex digests of the IDP's TLS certificate, matching
// the AWS-compatible thumbprint algorithm. Used at JWKS-fetch time when
// non-empty; an empty list means "trust the system root store".
Thumbprints []string `json:"thumbprints,omitempty"`
// AllowedPrincipalTagKeys is the per-provider ABAC allowlist for the
// `https://aws.amazon.com/tags/principal_tags` claim namespace. Empty
// means no tags are surfaced from this provider.
AllowedPrincipalTagKeys []string `json:"allowedPrincipalTagKeys,omitempty"`
// PolicyClaim, when non-empty, enables claim-based policy mode for this
// provider: tokens whose RoleArn matches the configured sentinel will
// derive their effective policies from this JWT claim.
PolicyClaim string `json:"policyClaim,omitempty"`
// Tags is the AWS-style tag set attached to the IAM resource itself
// (audit/inventory metadata, not propagated into sessions).
Tags map[string]string `json:"tags,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// OIDCProviderStore stores OIDCProviderRecord entries. Implementations are
// expected to be safe for concurrent use.
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)
ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error)
DeleteProvider(ctx context.Context, filerAddress string, arn string) error
}
// MemoryOIDCProviderStore is a process-local store, suitable for tests and
// single-node deployments. It also acts as the in-memory cache hydrated from
// static config at boot.
type MemoryOIDCProviderStore struct {
mu sync.RWMutex
providers map[string]*OIDCProviderRecord // keyed by ARN
}
// NewMemoryOIDCProviderStore creates an empty in-memory store.
func NewMemoryOIDCProviderStore() *MemoryOIDCProviderStore {
return &MemoryOIDCProviderStore{providers: make(map[string]*OIDCProviderRecord)}
}
// StoreProvider replaces any existing record with the same ARN.
func (m *MemoryOIDCProviderStore) StoreProvider(ctx context.Context, _ string, record *OIDCProviderRecord) error {
if record == nil {
return fmt.Errorf("record cannot be nil")
}
if record.ARN == "" {
return fmt.Errorf("record.ARN is required")
}
m.mu.Lock()
defer m.mu.Unlock()
m.providers[record.ARN] = copyOIDCProviderRecord(record)
return nil
}
// GetProviderByARN returns the record with the given ARN, or an error if absent.
func (m *MemoryOIDCProviderStore) GetProviderByARN(ctx context.Context, _ string, arn string) (*OIDCProviderRecord, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rec, ok := m.providers[arn]
if !ok {
return nil, fmt.Errorf("OIDC provider not found: %s", arn)
}
return copyOIDCProviderRecord(rec), nil
}
// GetProviderByIssuer scans for the first record whose URL matches `issuer`.
// Comparison strips trailing slashes and is case-insensitive on host.
func (m *MemoryOIDCProviderStore) GetProviderByIssuer(ctx context.Context, _ string, issuer string) (*OIDCProviderRecord, error) {
want := normalizeIssuer(issuer)
m.mu.RLock()
defer m.mu.RUnlock()
for _, rec := range m.providers {
if normalizeIssuer(rec.URL) == want {
return copyOIDCProviderRecord(rec), nil
}
}
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
}
// ListProviders returns every record sorted by ARN for stable output.
func (m *MemoryOIDCProviderStore) ListProviders(ctx context.Context, _ string) ([]*OIDCProviderRecord, error) {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]*OIDCProviderRecord, 0, len(m.providers))
for _, rec := range m.providers {
out = append(out, copyOIDCProviderRecord(rec))
}
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
return out, nil
}
// DeleteProvider removes the record. Idempotent — deleting a missing ARN is success.
func (m *MemoryOIDCProviderStore) DeleteProvider(ctx context.Context, _ string, arn string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.providers, arn)
return nil
}
// FilerOIDCProviderStore persists records as JSON files in a filer directory,
// mirroring FilerRoleStore.
type FilerOIDCProviderStore struct {
grpcDialOption grpc.DialOption
basePath string
filerAddressProvider func() string
}
// NewFilerOIDCProviderStore returns a filer-backed store. Default path
// `/etc/iam/oidc-providers` aligns with the existing roles directory.
func NewFilerOIDCProviderStore(config map[string]interface{}, filerAddressProvider func() string) *FilerOIDCProviderStore {
store := &FilerOIDCProviderStore{
basePath: "/etc/iam/oidc-providers",
filerAddressProvider: filerAddressProvider,
}
if config != nil {
if bp, ok := config["basePath"].(string); ok && bp != "" {
store.basePath = strings.TrimSuffix(bp, "/")
}
}
glog.V(2).Infof("Initialized FilerOIDCProviderStore with basePath %s", store.basePath)
return store
}
func (f *FilerOIDCProviderStore) resolveFilerAddress(filerAddress string) string {
if filerAddress != "" {
return filerAddress
}
if f.filerAddressProvider != nil {
return f.filerAddressProvider()
}
return ""
}
func (f *FilerOIDCProviderStore) fileName(arn string) string {
// Hash the ARN to a fixed-width filename so we can store any character set
// (including the URL fragments AWS lets through) without filer-path issues.
h := sha1.Sum([]byte(arn))
return hex.EncodeToString(h[:]) + ".json"
}
// StoreProvider persists `record` as JSON.
func (f *FilerOIDCProviderStore) StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error {
filerAddress = f.resolveFilerAddress(filerAddress)
if filerAddress == "" {
return fmt.Errorf("filer address is required")
}
if record == nil || record.ARN == "" {
return fmt.Errorf("record with ARN is required")
}
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return fmt.Errorf("marshal OIDC provider: %v", err)
}
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: f.basePath,
Entry: &filer_pb.Entry{
Name: f.fileName(record.ARN),
IsDirectory: false,
Attributes: &filer_pb.FuseAttributes{
Mtime: time.Now().Unix(),
Crtime: time.Now().Unix(),
FileMode: uint32(0o600),
},
Content: data,
},
})
if err != nil {
return fmt.Errorf("store OIDC provider %s: %v", record.ARN, err)
}
return nil
})
}
// GetProviderByARN looks up the record file by its ARN-derived filename.
func (f *FilerOIDCProviderStore) GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error) {
filerAddress = f.resolveFilerAddress(filerAddress)
if filerAddress == "" {
return nil, fmt.Errorf("filer address is required")
}
var data []byte
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: f.basePath,
Name: f.fileName(arn),
})
if err != nil {
return fmt.Errorf("OIDC provider not found: %v", err)
}
if resp.Entry == nil {
return fmt.Errorf("OIDC provider not found: %s", arn)
}
data = resp.Entry.Content
return nil
})
if err != nil {
return nil, err
}
var rec OIDCProviderRecord
if err := json.Unmarshal(data, &rec); err != nil {
return nil, fmt.Errorf("unmarshal OIDC provider: %v", err)
}
return &rec, nil
}
// GetProviderByIssuer linearly scans the directory. Phase 2b adds an issuer
// index when write throughput grows; for read-only this is good enough.
func (f *FilerOIDCProviderStore) GetProviderByIssuer(ctx context.Context, filerAddress string, issuer 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 {
return rec, nil
}
}
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
}
// ListProviders enumerates every record in the directory.
func (f *FilerOIDCProviderStore) ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error) {
filerAddress = f.resolveFilerAddress(filerAddress)
if filerAddress == "" {
return nil, fmt.Errorf("filer address is required")
}
var out []*OIDCProviderRecord
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
// Stream-paginate via StartFromFileName so deployments with more
// than 1000 providers don't get a silently truncated list.
const pageSize = 1000
startFrom := ""
for {
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
Directory: f.basePath,
Limit: pageSize,
StartFromFileName: startFrom,
InclusiveStartFrom: false,
})
if err != nil {
return fmt.Errorf("list OIDC providers: %v", err)
}
lastName := ""
pageCount := 0
for {
resp, recvErr := stream.Recv()
if recvErr != nil {
if errors.Is(recvErr, io.EOF) {
break
}
return fmt.Errorf("recv OIDC provider entry: %w", recvErr)
}
if resp.Entry == nil || resp.Entry.IsDirectory {
continue
}
lastName = resp.Entry.Name
pageCount++
if !strings.HasSuffix(resp.Entry.Name, ".json") {
continue
}
var rec OIDCProviderRecord
if err := json.Unmarshal(resp.Entry.Content, &rec); err != nil {
glog.Warningf("skipping malformed OIDC provider record %s: %v", resp.Entry.Name, err)
continue
}
out = append(out, &rec)
}
if pageCount < pageSize {
break
}
startFrom = lastName
}
return nil
})
if err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
return out, nil
}
// DeleteProvider removes the record. Missing ARN is treated as success.
func (f *FilerOIDCProviderStore) DeleteProvider(ctx context.Context, filerAddress string, arn string) error {
filerAddress = f.resolveFilerAddress(filerAddress)
if filerAddress == "" {
return fmt.Errorf("filer address is required")
}
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
Directory: f.basePath,
Name: f.fileName(arn),
IsDeleteData: true,
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
return nil
}
return fmt.Errorf("delete OIDC provider %s: %v", arn, err)
}
if resp.Error != "" && !strings.Contains(resp.Error, "not found") {
return fmt.Errorf("delete OIDC provider %s: %s", arn, resp.Error)
}
return nil
})
}
func (f *FilerOIDCProviderStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
}
// DeriveOIDCProviderARN turns an issuer URL into the canonical IAM ARN AWS uses.
// Empty `accountID` produces a global-style ARN.
//
// Examples:
//
// https://accounts.google.com -> arn:aws:iam::<acct>:oidc-provider/accounts.google.com
// https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED ->
// arn:aws:iam::<acct>:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED
func DeriveOIDCProviderARN(accountID, issuerURL string) (string, error) {
if issuerURL == "" {
return "", fmt.Errorf("issuer URL is required")
}
u, err := url.Parse(issuerURL)
if err != nil || u.Host == "" {
return "", fmt.Errorf("invalid issuer URL: %s", issuerURL)
}
host := strings.ToLower(u.Host)
resource := host + strings.TrimSuffix(u.Path, "/")
return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, resource), nil
}
// normalizeIssuer compares-friendly form: lowercased host, no trailing slash.
func normalizeIssuer(issuer string) string {
u, err := url.Parse(issuer)
if err != nil || u.Host == "" {
return strings.TrimSuffix(strings.ToLower(issuer), "/")
}
u.Host = strings.ToLower(u.Host)
u.Path = strings.TrimSuffix(u.Path, "/")
return u.String()
}
func copyOIDCProviderRecord(rec *OIDCProviderRecord) *OIDCProviderRecord {
if rec == nil {
return nil
}
cp := *rec
if rec.ClientIDs != nil {
cp.ClientIDs = append([]string(nil), rec.ClientIDs...)
}
if rec.Thumbprints != nil {
cp.Thumbprints = append([]string(nil), rec.Thumbprints...)
}
if rec.AllowedPrincipalTagKeys != nil {
cp.AllowedPrincipalTagKeys = append([]string(nil), rec.AllowedPrincipalTagKeys...)
}
if rec.Tags != nil {
cp.Tags = make(map[string]string, len(rec.Tags))
for k, v := range rec.Tags {
cp.Tags[k] = v
}
}
return &cp
}
@@ -0,0 +1,199 @@
package integration
import (
"context"
"strings"
"testing"
"time"
)
func newRecord(arn, url string) *OIDCProviderRecord {
now := time.Now()
return &OIDCProviderRecord{
ARN: arn,
URL: url,
ClientIDs: []string{"sts.amazonaws.com"},
CreatedAt: now,
UpdatedAt: now,
}
}
func TestMemoryStoreCRUD(t *testing.T) {
ctx := context.Background()
store := NewMemoryOIDCProviderStore()
rec := newRecord(
"arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com",
"https://token.actions.githubusercontent.com",
)
// Store + Get round-trip preserves the record.
if err := store.StoreProvider(ctx, "", rec); err != nil {
t.Fatalf("StoreProvider: %v", err)
}
got, err := store.GetProviderByARN(ctx, "", rec.ARN)
if err != nil {
t.Fatalf("GetProviderByARN: %v", err)
}
if got.URL != rec.URL {
t.Fatalf("URL mismatch: got=%s want=%s", got.URL, rec.URL)
}
// Mutate the returned copy and verify the store wasn't affected.
got.ClientIDs[0] = "tampered"
again, _ := store.GetProviderByARN(ctx, "", rec.ARN)
if again.ClientIDs[0] == "tampered" {
t.Fatal("store handed out a shared slice; mutations leaked back")
}
// List returns the entry.
all, err := store.ListProviders(ctx, "")
if err != nil {
t.Fatalf("ListProviders: %v", err)
}
if len(all) != 1 || all[0].ARN != rec.ARN {
t.Fatalf("ListProviders unexpected result: %+v", all)
}
// Delete -> Get returns not found.
if err := store.DeleteProvider(ctx, "", rec.ARN); err != nil {
t.Fatalf("DeleteProvider: %v", err)
}
if _, err := store.GetProviderByARN(ctx, "", rec.ARN); err == nil {
t.Fatal("expected not-found after delete")
}
// Delete is idempotent.
if err := store.DeleteProvider(ctx, "", rec.ARN); err != nil {
t.Fatalf("idempotent delete should succeed: %v", err)
}
}
func TestMemoryStoreGetByIssuerNormalizesHost(t *testing.T) {
ctx := context.Background()
store := NewMemoryOIDCProviderStore()
rec := newRecord(
"arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com",
"https://Token.Actions.GithubUserContent.com/", // mixed case + trailing slash
)
if err := store.StoreProvider(ctx, "", rec); err != nil {
t.Fatalf("StoreProvider: %v", err)
}
cases := []string{
"https://token.actions.githubusercontent.com",
"https://token.actions.githubusercontent.com/",
"https://TOKEN.actions.GITHUBUSERCONTENT.com",
}
for _, want := range cases {
got, err := store.GetProviderByIssuer(ctx, "", want)
if err != nil {
t.Errorf("issuer %q: GetProviderByIssuer: %v", want, err)
continue
}
if got.ARN != rec.ARN {
t.Errorf("issuer %q: ARN mismatch: got=%s", want, got.ARN)
}
}
}
func TestMemoryStoreGetByIssuerMissing(t *testing.T) {
ctx := context.Background()
store := NewMemoryOIDCProviderStore()
_, err := store.GetProviderByIssuer(ctx, "", "https://other.example/")
if err == nil {
t.Fatal("expected error for unregistered issuer")
}
}
func TestDeriveOIDCProviderARN(t *testing.T) {
cases := []struct {
name string
accountID string
issuer string
want string
}{
{
name: "google",
accountID: "111122223333",
issuer: "https://accounts.google.com",
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
},
{
name: "EKS with path",
accountID: "999999999999",
issuer: "https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED",
want: "arn:aws:iam::999999999999:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED",
},
{
name: "uppercase host normalized",
accountID: "111122223333",
issuer: "https://Accounts.Google.com",
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
},
{
name: "trailing slash trimmed",
accountID: "111122223333",
issuer: "https://accounts.google.com/",
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
},
{
name: "empty account allowed",
accountID: "",
issuer: "https://accounts.google.com",
want: "arn:aws:iam:::oidc-provider/accounts.google.com",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := DeriveOIDCProviderARN(tc.accountID, tc.issuer)
if err != nil {
t.Fatalf("err: %v", err)
}
if got != tc.want {
t.Fatalf("got=%s want=%s", got, tc.want)
}
})
}
}
func TestDeriveOIDCProviderARNRejectsBadInput(t *testing.T) {
cases := []string{"", "not a url", "http://"}
for _, in := range cases {
if _, err := DeriveOIDCProviderARN("123", in); err == nil {
t.Errorf("expected error for input %q", in)
}
}
}
func TestStoreRejectsEmptyARN(t *testing.T) {
ctx := context.Background()
store := NewMemoryOIDCProviderStore()
rec := newRecord("", "https://issuer/")
if err := store.StoreProvider(ctx, "", rec); err == nil {
t.Fatal("expected error storing record with empty ARN")
}
}
func TestStoreRejectsNil(t *testing.T) {
ctx := context.Background()
store := NewMemoryOIDCProviderStore()
if err := store.StoreProvider(ctx, "", nil); err == nil {
t.Fatal("expected error storing nil record")
}
}
func TestNormalizeIssuerRejectsEmpty(t *testing.T) {
if got := normalizeIssuer(""); got != "" {
t.Fatalf("empty issuer should normalize to empty, got %q", got)
}
}
func TestNormalizeIssuerHandlesNonURL(t *testing.T) {
// Defense in depth: even when issuer is junk, normalize doesn't panic and
// at least lowercases the input.
got := normalizeIssuer("Some Random String/")
if !strings.Contains(got, "some random") {
t.Fatalf("normalize should lowercase non-URL input, got %q", got)
}
}
+33
View File
@@ -13,6 +13,39 @@ type CommonResponse struct {
} `xml:"ResponseMetadata"`
}
// IAMTag mirrors AWS IAM's Tag list element with the Key/Value pair shape.
type IAMTag struct {
Key string `xml:"Key"`
Value string `xml:"Value"`
}
// OpenIDConnectProviderListEntry is one element of ListOpenIDConnectProviders.
type OpenIDConnectProviderListEntry struct {
Arn string `xml:"Arn"`
}
// ListOpenIDConnectProvidersResponse is the response for ListOpenIDConnectProviders.
type ListOpenIDConnectProvidersResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListOpenIDConnectProvidersResponse"`
ListOpenIDConnectProvidersResult struct {
OpenIDConnectProviderList []*OpenIDConnectProviderListEntry `xml:"OpenIDConnectProviderList>member"`
} `xml:"ListOpenIDConnectProvidersResult"`
CommonResponse
}
// GetOpenIDConnectProviderResponse is the response for GetOpenIDConnectProvider.
type GetOpenIDConnectProviderResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetOpenIDConnectProviderResponse"`
GetOpenIDConnectProviderResult struct {
Url string `xml:"Url"`
ClientIDList []string `xml:"ClientIDList>member,omitempty"`
ThumbprintList []string `xml:"ThumbprintList>member,omitempty"`
Tags []*IAMTag `xml:"Tags>member,omitempty"`
CreateDate string `xml:"CreateDate,omitempty"`
} `xml:"GetOpenIDConnectProviderResult"`
CommonResponse
}
// SetRequestId stores the request ID generated for the current HTTP request.
func (r *CommonResponse) SetRequestId(requestID string) {
r.ResponseMetadata.RequestId = requestID
+12 -1
View File
@@ -2165,13 +2165,24 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s
if e.readOnly {
switch action {
case "ListUsers", "ListAccessKeys", "GetUser", "GetUserPolicy", "ListUserPolicies", "ListAttachedUserPolicies", "ListPolicies", "GetPolicy", "ListPolicyVersions", "GetPolicyVersion", "ListServiceAccounts", "GetServiceAccount",
"GetGroup", "ListGroups", "ListAttachedGroupPolicies", "GetGroupPolicy", "ListGroupPolicies", "ListGroupsForUser":
"GetGroup", "ListGroups", "ListAttachedGroupPolicies", "GetGroupPolicy", "ListGroupPolicies", "ListGroupsForUser",
actionListOpenIDConnectProviders, actionGetOpenIDConnectProvider:
// Allowed read-only actions
default:
return nil, &iamError{Code: s3err.GetAPIError(s3err.ErrAccessDenied).Code, Error: fmt.Errorf("IAM write operations are disabled on this server")}
}
}
// OIDC provider actions don't operate on S3ApiConfiguration; dispatch
// before the unrelated config load + reload churn.
if response, iamErr, ok := e.dispatchOIDCProviderAction(ctx, values); ok {
if iamErr != nil {
return nil, iamErr
}
response.SetRequestId(reqID)
return response, nil
}
s3cfg := &iam_pb.S3ApiConfiguration{}
if err := e.GetS3ApiConfiguration(s3cfg); err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
return nil, &iamError{Code: s3err.GetAPIError(s3err.ErrInternalError).Code, Error: fmt.Errorf("failed to get s3 api configuration: %v", err)}
+118
View File
@@ -0,0 +1,118 @@
package s3api
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"github.com/aws/aws-sdk-go/service/iam"
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
)
// OIDC provider IAM actions handled by this file. Mutating actions are
// reserved for Phase 2b; the read-only set lands now so existing static-config
// providers become discoverable through the AWS IAM API.
const (
actionGetOpenIDConnectProvider = "GetOpenIDConnectProvider"
actionListOpenIDConnectProviders = "ListOpenIDConnectProviders"
)
// isOIDCProviderAction reports whether an action belongs to the OIDC provider
// family. Used by ExecuteAction to short-circuit the S3ApiConfiguration code
// path for actions that don't operate on it.
func isOIDCProviderAction(action string) bool {
switch action {
case actionGetOpenIDConnectProvider,
actionListOpenIDConnectProviders:
return true
default:
return false
}
}
// dispatchOIDCProviderAction handles the OIDC provider IAM actions. Returns a
// response, the IAM error if any, and a boolean indicating whether the action
// was recognised (so the caller can fall through when false).
func (e *EmbeddedIamApi) dispatchOIDCProviderAction(ctx context.Context, values url.Values) (iamlib.RequestIDSetter, *iamError, bool) {
if !isOIDCProviderAction(values.Get("Action")) {
return nil, nil, false
}
mgr := e.oidcIAMManager()
if mgr == nil {
return nil, &iamError{
Code: iam.ErrCodeServiceFailureException,
Error: errors.New("OIDC provider store not configured"),
}, true
}
switch values.Get("Action") {
case actionListOpenIDConnectProviders:
resp, err := e.listOpenIDConnectProviders(ctx, mgr)
return resp, err, true
case actionGetOpenIDConnectProvider:
resp, err := e.getOpenIDConnectProvider(ctx, mgr, values)
return resp, err, true
}
return nil, nil, false
}
func (e *EmbeddedIamApi) oidcIAMManager() *integration.IAMManager {
if e.iam == nil || e.iam.iamIntegration == nil {
return nil
}
provider, ok := e.iam.iamIntegration.(IAMManagerProvider)
if !ok {
return nil
}
return provider.GetIAMManager()
}
func (e *EmbeddedIamApi) listOpenIDConnectProviders(ctx context.Context, mgr *integration.IAMManager) (*iamlib.ListOpenIDConnectProvidersResponse, *iamError) {
records, err := mgr.ListOIDCProviders(ctx)
if err != nil {
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
resp := &iamlib.ListOpenIDConnectProvidersResponse{}
resp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList = make([]*iamlib.OpenIDConnectProviderListEntry, 0, len(records))
for _, rec := range records {
resp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList = append(
resp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList,
&iamlib.OpenIDConnectProviderListEntry{Arn: rec.ARN},
)
}
return resp, nil
}
func (e *EmbeddedIamApi) getOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.GetOpenIDConnectProviderResponse, *iamError) {
arn := strings.TrimSpace(values.Get("OpenIDConnectProviderArn"))
if arn == "" {
return nil, &iamError{
Code: iam.ErrCodeInvalidInputException,
Error: fmt.Errorf("OpenIDConnectProviderArn is required"),
}
}
rec, err := mgr.GetOIDCProvider(ctx, arn)
if err != nil {
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
}
resp := &iamlib.GetOpenIDConnectProviderResponse{}
resp.GetOpenIDConnectProviderResult.Url = rec.URL
resp.GetOpenIDConnectProviderResult.ClientIDList = append([]string(nil), rec.ClientIDs...)
resp.GetOpenIDConnectProviderResult.ThumbprintList = append([]string(nil), rec.Thumbprints...)
if !rec.CreatedAt.IsZero() {
// AWS uses ISO-8601; the IAM XML format accepts time.Time-string output.
resp.GetOpenIDConnectProviderResult.CreateDate = rec.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")
}
if len(rec.Tags) > 0 {
tags := make([]*iamlib.IAMTag, 0, len(rec.Tags))
for k, v := range rec.Tags {
tags = append(tags, &iamlib.IAMTag{Key: k, Value: v})
}
resp.GetOpenIDConnectProviderResult.Tags = tags
}
return resp, nil
}
+151
View File
@@ -0,0 +1,151 @@
package s3api
import (
"context"
"net/url"
"testing"
"time"
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
)
// stubIntegration is the smallest IAMManagerProvider that lets the OIDC
// dispatcher reach an IAMManager. The other IAMIntegration methods are
// unused by these tests and panic if invoked, which is what we want — any
// unexpected call signals a routing bug.
type stubIntegration struct {
IAMIntegration
mgr *integration.IAMManager
}
func (s *stubIntegration) GetIAMManager() *integration.IAMManager { return s.mgr }
func newOIDCTestAPI(t *testing.T) (*EmbeddedIamApiForTest, *integration.IAMManager) {
t.Helper()
mgr := integration.NewIAMManager()
cfg := &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"),
AccountId: "111122223333",
Providers: []*sts.ProviderConfig{
{
Name: "google",
Type: sts.ProviderTypeOIDC,
Enabled: true,
Config: map[string]interface{}{
"issuer": "https://accounts.google.com",
"clientId": "client-google",
},
},
{
Name: "github",
Type: sts.ProviderTypeOIDC,
Enabled: true,
Config: map[string]interface{}{
"issuer": "https://token.actions.githubusercontent.com",
"clientId": "sts.amazonaws.com",
},
},
},
},
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
Roles: &integration.RoleStoreConfig{StoreType: "memory"},
}
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
t.Fatalf("Initialize IAM manager: %v", err)
}
api := NewEmbeddedIamApiForTest()
api.iam.iamIntegration = &stubIntegration{mgr: mgr}
return api, mgr
}
func TestListOpenIDConnectProviders(t *testing.T) {
api, _ := newOIDCTestAPI(t)
values := url.Values{}
values.Set("Action", actionListOpenIDConnectProviders)
resp, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-1")
if iamErr != nil {
t.Fatalf("ExecuteAction: code=%s err=%v", iamErr.Code, iamErr.Error)
}
listResp, ok := resp.(*iamlib.ListOpenIDConnectProvidersResponse)
if !ok {
t.Fatalf("unexpected response type %T", resp)
}
got := listResp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList
if len(got) != 2 {
t.Fatalf("expected 2 providers, got %d", len(got))
}
}
func TestGetOpenIDConnectProvider(t *testing.T) {
api, _ := newOIDCTestAPI(t)
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
values := url.Values{}
values.Set("Action", actionGetOpenIDConnectProvider)
values.Set("OpenIDConnectProviderArn", arn)
resp, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-2")
if iamErr != nil {
t.Fatalf("ExecuteAction: code=%s err=%v", iamErr.Code, iamErr.Error)
}
getResp, ok := resp.(*iamlib.GetOpenIDConnectProviderResponse)
if !ok {
t.Fatalf("unexpected response type %T", resp)
}
if getResp.GetOpenIDConnectProviderResult.Url != "https://accounts.google.com" {
t.Fatalf("URL mismatch: %s", getResp.GetOpenIDConnectProviderResult.Url)
}
if len(getResp.GetOpenIDConnectProviderResult.ClientIDList) != 1 ||
getResp.GetOpenIDConnectProviderResult.ClientIDList[0] != "client-google" {
t.Fatalf("ClientIDList wrong: %v", getResp.GetOpenIDConnectProviderResult.ClientIDList)
}
}
func TestGetOpenIDConnectProviderMissing(t *testing.T) {
api, _ := newOIDCTestAPI(t)
values := url.Values{}
values.Set("Action", actionGetOpenIDConnectProvider)
values.Set("OpenIDConnectProviderArn", "arn:aws:iam::111122223333:oidc-provider/nope.example")
_, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-3")
if iamErr == nil {
t.Fatal("expected NoSuchEntity error")
}
if iamErr.Code != "NoSuchEntity" {
t.Fatalf("expected NoSuchEntity code, got %s", iamErr.Code)
}
}
func TestGetOpenIDConnectProviderRequiresArn(t *testing.T) {
api, _ := newOIDCTestAPI(t)
values := url.Values{}
values.Set("Action", actionGetOpenIDConnectProvider)
_, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-4")
if iamErr == nil {
t.Fatal("expected error for missing ARN")
}
if iamErr.Code != "InvalidInput" {
t.Fatalf("expected InvalidInput code, got %s", iamErr.Code)
}
}
func TestReadOnlyAllowsOIDCList(t *testing.T) {
api, _ := newOIDCTestAPI(t)
api.readOnly = true
values := url.Values{}
values.Set("Action", actionListOpenIDConnectProviders)
if _, iamErr := api.ExecuteAction(context.Background(), values, true, "ro-1"); iamErr != nil {
t.Fatalf("read-only mode should allow ListOpenIDConnectProviders: %v", iamErr.Error)
}
}