diff --git a/weed/iam/integration/iam_manager.go b/weed/iam/integration/iam_manager.go index 4af2587c3..41ed38cd8 100644 --- a/weed/iam/integration/iam_manager.go +++ b/weed/iam/integration/iam_manager.go @@ -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 { diff --git a/weed/iam/integration/oidc_provider_bootstrap_test.go b/weed/iam/integration/oidc_provider_bootstrap_test.go new file mode 100644 index 000000000..06a79c6a3 --- /dev/null +++ b/weed/iam/integration/oidc_provider_bootstrap_test.go @@ -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") + } +} diff --git a/weed/iam/integration/oidc_provider_store.go b/weed/iam/integration/oidc_provider_store.go new file mode 100644 index 000000000..749ba7853 --- /dev/null +++ b/weed/iam/integration/oidc_provider_store.go @@ -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:::oidc-provider//. + 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:::oidc-provider/accounts.google.com +// https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED -> +// arn:aws:iam:::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 +} diff --git a/weed/iam/integration/oidc_provider_store_test.go b/weed/iam/integration/oidc_provider_store_test.go new file mode 100644 index 000000000..691f6384b --- /dev/null +++ b/weed/iam/integration/oidc_provider_store_test.go @@ -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) + } +} diff --git a/weed/iam/responses.go b/weed/iam/responses.go index 786a3aa20..bea0e3eb1 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -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 diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index b69585e72..57ab588a0 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -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)} diff --git a/weed/s3api/s3api_embedded_iam_oidc.go b/weed/s3api/s3api_embedded_iam_oidc.go new file mode 100644 index 000000000..467ba45d0 --- /dev/null +++ b/weed/s3api/s3api_embedded_iam_oidc.go @@ -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 +} diff --git a/weed/s3api/s3api_iam_oidc_test.go b/weed/s3api/s3api_iam_oidc_test.go new file mode 100644 index 000000000..653c38939 --- /dev/null +++ b/weed/s3api/s3api_iam_oidc_test.go @@ -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) + } +}