diff --git a/weed/iam/integration/iam_manager.go b/weed/iam/integration/iam_manager.go index 41ed38cd8..95d6c440b 100644 --- a/weed/iam/integration/iam_manager.go +++ b/weed/iam/integration/iam_manager.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "strings" "sync" @@ -11,6 +12,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/iam/oidc" "github.com/seaweedfs/seaweedfs/weed/iam/policy" "github.com/seaweedfs/seaweedfs/weed/iam/providers" "github.com/seaweedfs/seaweedfs/weed/iam/sts" @@ -67,6 +69,211 @@ func (m *IAMManager) ListOIDCProviders(ctx context.Context) ([]*OIDCProviderReco return m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress()) } +// CreateOIDCProvider persists a new IAM-managed OIDC provider record. Refuses +// to overwrite an existing record so callers see EntityAlreadyExists semantics. +func (m *IAMManager) CreateOIDCProvider(ctx context.Context, rec *OIDCProviderRecord) error { + if m.oidcProviderStore == nil { + return fmt.Errorf("OIDC provider store not configured") + } + if rec == nil { + return fmt.Errorf("record cannot be nil") + } + if err := validateOIDCProviderRecord(rec); err != nil { + return err + } + existing, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), rec.ARN) + if err == nil && existing != nil { + return fmt.Errorf("%w: %s", ErrOIDCProviderAlreadyExists, rec.ARN) + } + if err != nil && !errors.Is(err, ErrOIDCProviderNotFound) { + return fmt.Errorf("lookup existing OIDC provider %q: %w", rec.ARN, err) + } + now := time.Now().UTC() + rec.CreatedAt = now + rec.UpdatedAt = now + if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil { + return err + } + m.refreshOIDCProvidersBestEffort(ctx, "CreateOIDCProvider", rec.ARN) + return nil +} + +// DeleteOIDCProvider removes the IAM-managed record. Idempotent. +func (m *IAMManager) DeleteOIDCProvider(ctx context.Context, arn string) error { + if m.oidcProviderStore == nil { + return fmt.Errorf("OIDC provider store not configured") + } + if err := m.oidcProviderStore.DeleteProvider(ctx, m.getFilerAddress(), arn); err != nil { + return err + } + m.refreshOIDCProvidersBestEffort(ctx, "DeleteOIDCProvider", arn) + return nil +} + +// AddClientIDToOIDCProvider appends `clientID` to the provider's allowed +// audience list. Adding an existing client ID is a no-op (AWS-compat). +func (m *IAMManager) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error { + if m.oidcProviderStore == nil { + return fmt.Errorf("OIDC provider store not configured") + } + if clientID == "" { + return fmt.Errorf("ClientID cannot be empty") + } + rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn) + if err != nil { + return err + } + for _, existing := range rec.ClientIDs { + if existing == clientID { + return nil // idempotent + } + } + if len(rec.ClientIDs) >= 100 { + return fmt.Errorf("ClientIDList must contain at most 100 entries") + } + rec.ClientIDs = append(rec.ClientIDs, clientID) + rec.UpdatedAt = time.Now().UTC() + if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil { + return err + } + m.refreshOIDCProvidersBestEffort(ctx, "AddClientIDToOIDCProvider", arn) + return nil +} + +// RemoveClientIDFromOIDCProvider drops `clientID` from the allowed audience +// list. Removing a missing client ID is a no-op. +func (m *IAMManager) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error { + if m.oidcProviderStore == nil { + return fmt.Errorf("OIDC provider store not configured") + } + rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn) + if err != nil { + return err + } + pruned := make([]string, 0, len(rec.ClientIDs)) + for _, existing := range rec.ClientIDs { + if existing != clientID { + pruned = append(pruned, existing) + } + } + if len(pruned) == len(rec.ClientIDs) { + return nil // not present; no-op + } + rec.ClientIDs = pruned + rec.UpdatedAt = time.Now().UTC() + if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil { + return err + } + m.refreshOIDCProvidersBestEffort(ctx, "RemoveClientIDFromOIDCProvider", arn) + return nil +} + +// UpdateOIDCProviderThumbprints replaces the entire thumbprint list. AWS +// constrains the list to 1..5 entries when non-empty; we mirror that bound. +func (m *IAMManager) UpdateOIDCProviderThumbprints(ctx context.Context, arn string, thumbprints []string) error { + if m.oidcProviderStore == nil { + return fmt.Errorf("OIDC provider store not configured") + } + if len(thumbprints) > 5 { + return fmt.Errorf("ThumbprintList must contain at most 5 entries, got %d", len(thumbprints)) + } + for _, tp := range thumbprints { + if !isValidSHA1Thumbprint(tp) { + return fmt.Errorf("invalid thumbprint %q: must be 40-character SHA-1 hex", tp) + } + } + rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn) + if err != nil { + return err + } + rec.Thumbprints = append([]string(nil), thumbprints...) + rec.UpdatedAt = time.Now().UTC() + if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil { + return err + } + m.refreshOIDCProvidersBestEffort(ctx, "UpdateOIDCProviderThumbprints", arn) + return nil +} + +// TagOIDCProvider merges the supplied tags into the provider's tag set. +func (m *IAMManager) TagOIDCProvider(ctx context.Context, arn string, tags map[string]string) error { + if m.oidcProviderStore == nil { + return fmt.Errorf("OIDC provider store not configured") + } + rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn) + if err != nil { + return err + } + if rec.Tags == nil { + rec.Tags = make(map[string]string, len(tags)) + } + for k, v := range tags { + rec.Tags[k] = v + } + rec.UpdatedAt = time.Now().UTC() + return m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec) +} + +// UntagOIDCProvider removes the named tags from the provider's tag set. +func (m *IAMManager) UntagOIDCProvider(ctx context.Context, arn string, keys []string) error { + if m.oidcProviderStore == nil { + return fmt.Errorf("OIDC provider store not configured") + } + rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn) + if err != nil { + return err + } + for _, k := range keys { + delete(rec.Tags, k) + } + rec.UpdatedAt = time.Now().UTC() + return m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec) +} + +// validateOIDCProviderRecord enforces the invariants AWS imposes on the +// underlying CreateOpenIDConnectProvider call. +func validateOIDCProviderRecord(rec *OIDCProviderRecord) error { + if rec.URL == "" { + return fmt.Errorf("Url is required") + } + if rec.ARN == "" { + return fmt.Errorf("ARN is required") + } + if len(rec.ClientIDs) == 0 { + return fmt.Errorf("ClientIDList must contain at least one entry") + } + if len(rec.ClientIDs) > 100 { + return fmt.Errorf("ClientIDList must contain at most 100 entries") + } + if len(rec.Thumbprints) > 5 { + return fmt.Errorf("ThumbprintList must contain at most 5 entries") + } + for _, tp := range rec.Thumbprints { + if !isValidSHA1Thumbprint(tp) { + return fmt.Errorf("invalid thumbprint %q: must be 40-character SHA-1 hex", tp) + } + } + return nil +} + +// isValidSHA1Thumbprint returns true iff `s` is exactly 40 hex characters, +// matching the SHA-1 digest format AWS expects. +func isValidSHA1Thumbprint(s string) bool { + if len(s) != 40 { + return false + } + for _, r := range s { + switch { + case r >= '0' && r <= '9': + case r >= 'a' && r <= 'f': + case r >= 'A' && r <= 'F': + default: + return false + } + } + return true +} + // IAMConfig holds configuration for all IAM components type IAMConfig struct { // STS service configuration @@ -310,6 +517,70 @@ func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error { return nil } +// refreshOIDCProvidersBestEffort calls RefreshOIDCProvidersFromStore and +// logs a warning on failure. The IAM API call has already succeeded by the +// time we get here, so a refresh failure must not surface to the caller — +// the worst case is that the local instance keeps the stale runtime view +// until a peer's metadata-subscribe event triggers another refresh. +func (m *IAMManager) refreshOIDCProvidersBestEffort(ctx context.Context, op, arn string) { + if err := m.RefreshOIDCProvidersFromStore(ctx); err != nil { + glog.Warningf("refresh OIDC providers after %s on %s: %v", op, arn, err) + } +} + +// RefreshOIDCProvidersFromStore reloads every OIDCProviderRecord from the +// configured store and pushes the resulting runtime providers into the STS +// service so AssumeRoleWithWebIdentity sees the latest set without a +// restart. Safe to call when no store is configured (returns nil) and when +// the store is empty (clears the IAM-managed map). Records with empty URLs +// or invalid configuration are logged and skipped so a single bad entry +// does not stop the rest from refreshing. +func (m *IAMManager) RefreshOIDCProvidersFromStore(ctx context.Context) error { + if m.oidcProviderStore == nil || m.stsService == nil { + return nil + } + records, err := m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress()) + if err != nil { + return fmt.Errorf("list OIDC providers: %w", err) + } + byIssuer := make(map[string]providers.IdentityProvider, len(records)) + for _, rec := range records { + if rec == nil || rec.URL == "" { + continue + } + provider, err := buildOIDCProviderFromRecord(rec) + if err != nil { + glog.Warningf("skip refreshing OIDC provider %s: %v", rec.ARN, err) + continue + } + // Last write wins on issuer collision; the store is the source of + // truth, and an operator who has two records with the same issuer + // has already accepted one will shadow the other. + byIssuer[rec.URL] = provider + } + m.stsService.SetIAMManagedOIDCProvidersByIssuer(byIssuer) + return nil +} + +// buildOIDCProviderFromRecord turns a stored record into a runtime +// OIDCProvider. The provider name is the ARN so re-registration is +// idempotent and never collides with static-config entries. +func buildOIDCProviderFromRecord(rec *OIDCProviderRecord) (*oidc.OIDCProvider, error) { + if rec == nil { + return nil, fmt.Errorf("record cannot be nil") + } + cfg := &oidc.OIDCConfig{ + Issuer: rec.URL, + ClientIDs: append([]string(nil), rec.ClientIDs...), + Thumbprints: append([]string(nil), rec.Thumbprints...), + } + provider := oidc.NewOIDCProvider(rec.ARN) + if err := provider.Initialize(cfg); err != nil { + return nil, err + } + return provider, nil +} + // createOIDCProviderStore selects an OIDCProviderStore implementation. Defaults // to memory; "filer" requires a filerAddressProvider to be configured. func (m *IAMManager) createOIDCProviderStore(cfg *OIDCProviderStoreConfig) (OIDCProviderStore, error) { diff --git a/weed/iam/integration/oidc_provider_store.go b/weed/iam/integration/oidc_provider_store.go index 749ba7853..5f4e1a5c1 100644 --- a/weed/iam/integration/oidc_provider_store.go +++ b/weed/iam/integration/oidc_provider_store.go @@ -20,6 +20,15 @@ import ( "google.golang.org/grpc" ) +// Sentinel errors returned by the IAM manager and OIDCProviderStore. Callers +// in the s3api layer use errors.Is rather than substring-matching the +// formatted message, so the message can be edited freely without changing +// the IAM error code surfaced to the API caller. +var ( + ErrOIDCProviderNotFound = errors.New("OIDC provider not found") + ErrOIDCProviderAlreadyExists = errors.New("OIDC provider already exists") +) + // 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 @@ -109,7 +118,7 @@ func (m *MemoryOIDCProviderStore) GetProviderByARN(ctx context.Context, _ string defer m.mu.RUnlock() rec, ok := m.providers[arn] if !ok { - return nil, fmt.Errorf("OIDC provider not found: %s", arn) + return nil, fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn) } return copyOIDCProviderRecord(rec), nil } @@ -239,7 +248,7 @@ func (f *FilerOIDCProviderStore) GetProviderByARN(ctx context.Context, filerAddr Name: f.fileName(arn), }) if err != nil { - return fmt.Errorf("OIDC provider not found: %v", err) + return fmt.Errorf("%w: %v", ErrOIDCProviderNotFound, err) } if resp.Entry == nil { return fmt.Errorf("OIDC provider not found: %s", arn) diff --git a/weed/iam/oidc/oidc_provider.go b/weed/iam/oidc/oidc_provider.go index c9f289aa6..81e7c28a1 100644 --- a/weed/iam/oidc/oidc_provider.go +++ b/weed/iam/oidc/oidc_provider.go @@ -5,9 +5,11 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rsa" + "crypto/sha1" "crypto/tls" "crypto/x509" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -48,8 +50,15 @@ type OIDCConfig struct { // Issuer is the OIDC issuer URL Issuer string `json:"issuer"` - // ClientID is the OAuth2 client ID - ClientID string `json:"clientId"` + // ClientID is the OAuth2 client ID. Either ClientID or ClientIDs is + // required; when both are present, ClientID is appended to the audience + // allowlist. + ClientID string `json:"clientId,omitempty"` + + // ClientIDs is the AWS-compatible audience allowlist. Tokens are accepted + // when any of `aud` or `azp` matches any entry. Mutually compatible with + // ClientID for backward compatibility. + ClientIDs []string `json:"clientIds,omitempty"` // ClientSecret is the OAuth2 client secret (optional for public clients) ClientSecret string `json:"clientSecret,omitempty"` @@ -72,6 +81,12 @@ type OIDCConfig struct { // JWKSCacheTTLSeconds sets how long to cache JWKS before refresh (default 3600 seconds) JWKSCacheTTLSeconds int `json:"jwksCacheTTLSeconds,omitempty"` + // Thumbprints, when non-empty, pins the issuer's TLS certificate against + // this allowlist of SHA-1 hex digests. Matches the AWS IAM + // CreateOpenIDConnectProvider semantics. Empty means "trust the system + // root store" (or whatever TLSCACert configures). + Thumbprints []string `json:"thumbprints,omitempty"` + // TLSCACert is the path to the CA certificate file for custom/self-signed certificates TLSCACert string `json:"tlsCaCert,omitempty"` @@ -80,6 +95,64 @@ type OIDCConfig struct { TLSInsecureSkipVerify bool `json:"tlsInsecureSkipVerify,omitempty"` } +// normalizeThumbprints lowercases and de-duplicates the configured allowlist. +// Returns a set keyed by lowercase hex for O(1) lookup during TLS verification. +func normalizeThumbprints(in []string) map[string]struct{} { + out := make(map[string]struct{}, len(in)) + for _, t := range in { + t = strings.ToLower(strings.TrimSpace(t)) + if t == "" { + continue + } + out[t] = struct{}{} + } + return out +} + +// verifyThumbprintMatch checks that some certificate in the negotiated chain +// hashes to a thumbprint in `expected`. AWS pins the certificate immediately +// below the root in the chain; we accept any chain certificate to also cover +// self-signed deployments and skip-verify test setups. When the chain has not +// been built (InsecureSkipVerify), we fall back to PeerCertificates. +func verifyThumbprintMatch(cs tls.ConnectionState, expected map[string]struct{}) error { + candidates := collectThumbprintCandidates(cs) + for _, c := range candidates { + sum := sha1.Sum(c.Raw) + hexSum := hex.EncodeToString(sum[:]) + if _, ok := expected[hexSum]; ok { + return nil + } + } + return fmt.Errorf("OIDC TLS thumbprint did not match any configured allowlist entry") +} + +// collectThumbprintCandidates flattens the verified chains and peer cert list +// into a single slice of unique certificates worth checking. +func collectThumbprintCandidates(cs tls.ConnectionState) []*x509.Certificate { + var out []*x509.Certificate + seen := map[string]struct{}{} + add := func(c *x509.Certificate) { + if c == nil { + return + } + k := string(c.Raw) + if _, ok := seen[k]; ok { + return + } + seen[k] = struct{}{} + out = append(out, c) + } + for _, chain := range cs.VerifiedChains { + for _, c := range chain { + add(c) + } + } + for _, c := range cs.PeerCertificates { + add(c) + } + return out +} + // JWKS represents JSON Web Key Set type JWKS struct { Keys []JWK `json:"keys"` @@ -154,6 +227,20 @@ func (p *OIDCProvider) Initialize(config interface{}) error { glog.Warningf("OIDC provider %q is configured to skip TLS verification. This is insecure and should not be used in production.", p.name) } + // Thumbprint pinning: when configured, every TLS handshake to the IDP must + // present a chain whose terminal certificate (the cert just below the root, + // matching AWS IAM semantics) hashes to one of the listed SHA-1 digests. + // VerifyConnection runs after the chain build, so cs.VerifiedChains is + // populated when InsecureSkipVerify is false; we additionally pin against + // PeerCertificates so self-signed test setups work too. + if len(oidcConfig.Thumbprints) > 0 { + expected := normalizeThumbprints(oidcConfig.Thumbprints) + tlsConfig.VerifyConnection = func(cs tls.ConnectionState) error { + return verifyThumbprintMatch(cs, expected) + } + glog.V(2).Infof("OIDC provider %q: TLS thumbprint pinning enabled (%d allowed)", p.name, len(expected)) + } + if oidcConfig.TLSCACert != "" { // Validate that the CA cert path is absolute to prevent reading unintended files if !filepath.IsAbs(oidcConfig.TLSCACert) { @@ -193,7 +280,7 @@ func (p *OIDCProvider) validateConfig(config *OIDCConfig) error { return fmt.Errorf("issuer is required") } - if config.ClientID == "" { + if config.ClientID == "" && len(config.ClientIDs) == 0 { return fmt.Errorf("client ID is required") } @@ -205,6 +292,32 @@ func (p *OIDCProvider) validateConfig(config *OIDCConfig) error { return nil } +// allowedAudiences returns the merged list of acceptable audiences for this +// provider. Both the singular ClientID and the plural ClientIDs are honoured; +// duplicates collapse silently. +func (p *OIDCProvider) allowedAudiences() []string { + if p.config == nil { + return nil + } + seen := map[string]struct{}{} + var out []string + add := func(s string) { + if s == "" { + return + } + if _, ok := seen[s]; ok { + return + } + seen[s] = struct{}{} + out = append(out, s) + } + add(p.config.ClientID) + for _, c := range p.config.ClientIDs { + add(c) + } + return out +} + // Authenticate authenticates a user with an OIDC token func (p *OIDCProvider) Authenticate(ctx context.Context, token string) (*providers.ExternalIdentity, error) { if !p.initialized { @@ -432,33 +545,45 @@ func (p *OIDCProvider) ValidateToken(ctx context.Context, token string) (*provid return nil, fmt.Errorf("%w: expected %s, got %s", providers.ErrProviderInvalidIssuer, p.config.Issuer, issuer) } - // Check audience claim (aud) or authorized party (azp) - Keycloak uses azp - // Per RFC 7519, aud can be either a string or an array of strings + // Check audience claim (aud) or authorized party (azp) — Keycloak uses azp. + // Per RFC 7519, aud can be either a string or an array of strings. + // Multiple client IDs are supported per AWS IAM CreateOpenIDConnectProvider + // semantics: any one match in the allowlist accepts the token. + allowed := p.allowedAudiences() + allowedSet := make(map[string]struct{}, len(allowed)) + for _, a := range allowed { + allowedSet[a] = struct{}{} + } + var audienceMatched bool if audClaim, ok := claims["aud"]; ok { switch aud := audClaim.(type) { case string: - if aud == p.config.ClientID { + if _, ok := allowedSet[aud]; ok { audienceMatched = true } case []interface{}: for _, a := range aud { - if str, ok := a.(string); ok && str == p.config.ClientID { - audienceMatched = true - break + if str, ok := a.(string); ok { + if _, ok := allowedSet[str]; ok { + audienceMatched = true + break + } } } } } if !audienceMatched { - if azp, ok := claims["azp"].(string); ok && azp == p.config.ClientID { - audienceMatched = true + if azp, ok := claims["azp"].(string); ok { + if _, ok := allowedSet[azp]; ok { + audienceMatched = true + } } } if !audienceMatched { - return nil, fmt.Errorf("%w: expected client ID %s", providers.ErrProviderInvalidAudience, p.config.ClientID) + return nil, fmt.Errorf("%w: token audience matches none of the configured client IDs", providers.ErrProviderInvalidAudience) } subject, ok := claims["sub"].(string) diff --git a/weed/iam/oidc/thumbprint_test.go b/weed/iam/oidc/thumbprint_test.go new file mode 100644 index 000000000..e0af9c9f5 --- /dev/null +++ b/weed/iam/oidc/thumbprint_test.go @@ -0,0 +1,153 @@ +package oidc + +import ( + "context" + "crypto/sha1" + "crypto/tls" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// thumbprintFromTLS returns the SHA-1 hex of the first peer certificate the +// given TLS server presents. It mirrors the pinning algorithm used by the +// OIDC provider so the test is hashing the cert that will actually be +// verified at runtime. +func thumbprintFromTLS(t *testing.T, server *httptest.Server) string { + t.Helper() + if server.TLS == nil || len(server.Certificate().Raw) == 0 { + t.Fatal("server has no TLS certificate") + } + sum := sha1.Sum(server.Certificate().Raw) + return hex.EncodeToString(sum[:]) +} + +// newTLSIDP starts a TLS-only test IDP that always serves a static JWKS. +// httptest TLS servers use a self-signed certificate by default, which means +// callers who want a successful handshake must either trust that cert or +// use TLSInsecureSkipVerify. +func newTLSIDP(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(JWKS{Keys: []JWK{{Kty: "RSA", Kid: "k1", Use: "sig", Alg: "RS256", N: "AQAB", E: "AQAB"}}}) + }) + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) // force fallback path + }) + server := httptest.NewTLSServer(mux) + t.Cleanup(server.Close) + return server +} + +// pinnedProvider configures an OIDC provider that pins TLS to the given +// thumbprint allowlist and trusts only the test server's self-signed cert. +func pinnedProvider(t *testing.T, server *httptest.Server, thumbprints []string) *OIDCProvider { + t.Helper() + p := NewOIDCProvider("thumbprint-test") + cfg := &OIDCConfig{ + Issuer: server.URL, + ClientID: "anything", + Thumbprints: thumbprints, + TLSInsecureSkipVerify: true, // we're doing the trust decision via thumbprint + } + if err := p.Initialize(cfg); err != nil { + t.Fatalf("Initialize: %v", err) + } + // Replace the inner transport's RootCAs with the server's cert so the chain + // builder also accepts it; this exercises the verified-chain path. + if transport, ok := p.httpClient.Transport.(*http.Transport); ok && transport.TLSClientConfig != nil { + transport.TLSClientConfig.InsecureSkipVerify = false + transport.TLSClientConfig.RootCAs = server.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs + } + return p +} + +func TestThumbprintMatchAccepted(t *testing.T) { + server := newTLSIDP(t) + tp := thumbprintFromTLS(t, server) + p := pinnedProvider(t, server, []string{tp}) + + if err := p.fetchJWKS(context.Background()); err != nil { + t.Fatalf("fetchJWKS with matching thumbprint should succeed: %v", err) + } +} + +func TestThumbprintMismatchRejected(t *testing.T) { + server := newTLSIDP(t) + // Flip the last byte so the digest can't match anything legitimate. + bad := "0000000000000000000000000000000000000000" + p := pinnedProvider(t, server, []string{bad}) + + err := p.fetchJWKS(context.Background()) + if err == nil { + t.Fatal("fetchJWKS with mismatched thumbprint should fail") + } +} + +func TestThumbprintAllowlistAcceptsMixedCase(t *testing.T) { + server := newTLSIDP(t) + tp := thumbprintFromTLS(t, server) + // Mix uppercase + whitespace to ensure normalization is applied. + p := pinnedProvider(t, server, []string{" " + uppercase(tp) + " "}) + if err := p.fetchJWKS(context.Background()); err != nil { + t.Fatalf("normalized thumbprint should match: %v", err) + } +} + +// uppercase returns s with letters uppercased — cheap helper avoiding a +// dependency on strings.ToUpper at the package level for clarity. +func uppercase(s string) string { + out := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'a' && c <= 'z' { + c -= 32 + } + out[i] = c + } + return string(out) +} + +// guard against a future refactor that drops VerifyConnection by checking +// that omitting Thumbprints leaves the connection unverified-by-pin. +func TestThumbprintEmptyAllowlistSkipsCheck(t *testing.T) { + server := newTLSIDP(t) + p := pinnedProvider(t, server, nil) + transport, ok := p.httpClient.Transport.(*http.Transport) + if !ok { + t.Fatal("expected *http.Transport") + } + if transport.TLSClientConfig.VerifyConnection != nil { + t.Fatal("VerifyConnection must be nil when no thumbprints are configured") + } + // And the full TLS handshake still succeeds normally. + if err := p.fetchJWKS(context.Background()); err != nil { + t.Fatalf("fetchJWKS with no thumbprints: %v", err) + } +} + +// Ensure the underlying tls.Config still rejects truly invalid handshakes +// when InsecureSkipVerify is on but no thumbprint allowlist is configured — +// in that case we explicitly want to *trust* the connection (skip-verify), +// just as the existing test code did. +func TestThumbprintSkipVerifyHonoured(t *testing.T) { + server := newTLSIDP(t) + p := NewOIDCProvider("skip") + if err := p.Initialize(&OIDCConfig{ + Issuer: server.URL, + ClientID: "x", + TLSInsecureSkipVerify: true, + }); err != nil { + t.Fatalf("Initialize: %v", err) + } + transport := p.httpClient.Transport.(*http.Transport) + if !transport.TLSClientConfig.InsecureSkipVerify { + t.Fatal("InsecureSkipVerify should be set") + } + if got := transport.TLSClientConfig.MinVersion; got != tls.VersionTLS12 { + t.Fatalf("MinVersion = %v want TLS12", got) + } +} diff --git a/weed/iam/responses.go b/weed/iam/responses.go index bea0e3eb1..21fb0ebbb 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -46,6 +46,52 @@ type GetOpenIDConnectProviderResponse struct { CommonResponse } +// CreateOpenIDConnectProviderResponse is the response for CreateOpenIDConnectProvider. +type CreateOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateOpenIDConnectProviderResponse"` + CreateOpenIDConnectProviderResult struct { + OpenIDConnectProviderArn string `xml:"OpenIDConnectProviderArn"` + Tags []*IAMTag `xml:"Tags>member,omitempty"` + } `xml:"CreateOpenIDConnectProviderResult"` + CommonResponse +} + +// DeleteOpenIDConnectProviderResponse is the response for DeleteOpenIDConnectProvider. +type DeleteOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteOpenIDConnectProviderResponse"` + CommonResponse +} + +// AddClientIDToOpenIDConnectProviderResponse is the response for AddClientIDToOpenIDConnectProvider. +type AddClientIDToOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AddClientIDToOpenIDConnectProviderResponse"` + CommonResponse +} + +// RemoveClientIDFromOpenIDConnectProviderResponse is the response for RemoveClientIDFromOpenIDConnectProvider. +type RemoveClientIDFromOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ RemoveClientIDFromOpenIDConnectProviderResponse"` + CommonResponse +} + +// UpdateOpenIDConnectProviderThumbprintResponse is the response for UpdateOpenIDConnectProviderThumbprint. +type UpdateOpenIDConnectProviderThumbprintResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateOpenIDConnectProviderThumbprintResponse"` + CommonResponse +} + +// TagOpenIDConnectProviderResponse is the response for TagOpenIDConnectProvider. +type TagOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagOpenIDConnectProviderResponse"` + CommonResponse +} + +// UntagOpenIDConnectProviderResponse is the response for UntagOpenIDConnectProvider. +type UntagOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagOpenIDConnectProviderResponse"` + 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/iam/sts/constants.go b/weed/iam/sts/constants.go index 6e293028b..65b8b1ffd 100644 --- a/weed/iam/sts/constants.go +++ b/weed/iam/sts/constants.go @@ -46,6 +46,8 @@ const ( ConfigFieldBasePath = "basePath" ConfigFieldIssuer = "issuer" ConfigFieldClientID = "clientId" + ConfigFieldClientIDs = "clientIds" + ConfigFieldThumbprints = "thumbprints" ConfigFieldClientSecret = "clientSecret" ConfigFieldJWKSUri = "jwksUri" ConfigFieldScopes = "scopes" diff --git a/weed/iam/sts/provider_factory.go b/weed/iam/sts/provider_factory.go index eb87d6d7e..44dc858b6 100644 --- a/weed/iam/sts/provider_factory.go +++ b/weed/iam/sts/provider_factory.go @@ -91,9 +91,20 @@ func (f *ProviderFactory) convertToOIDCConfig(configMap map[string]interface{}) return nil, fmt.Errorf(ErrIssuerRequired) } - if clientID, ok := configMap[ConfigFieldClientID].(string); ok { + clientIDPresent := false + if clientID, ok := configMap[ConfigFieldClientID].(string); ok && clientID != "" { config.ClientID = clientID - } else { + clientIDPresent = true + } + // Accept the AWS-compatible plural form. Either field satisfies the + // "at least one client id" requirement; both can be set together. + if rawList, ok := configMap[ConfigFieldClientIDs]; ok { + if list, err := f.convertToStringSlice(rawList); err == nil && len(list) > 0 { + config.ClientIDs = list + clientIDPresent = true + } + } + if !clientIDPresent { return nil, fmt.Errorf(ErrClientIDRequired) } @@ -123,6 +134,12 @@ func (f *ProviderFactory) convertToOIDCConfig(configMap map[string]interface{}) config.TLSCACert = tlsCaCert } + if rawThumbprints, ok := configMap[ConfigFieldThumbprints]; ok { + if list, err := f.convertToStringSlice(rawThumbprints); err == nil { + config.Thumbprints = list + } + } + if tlsInsecureSkipVerify, ok := configMap[ConfigFieldTLSInsecureSkipVerify].(bool); ok { config.TLSInsecureSkipVerify = tlsInsecureSkipVerify } diff --git a/weed/iam/sts/sts_service.go b/weed/iam/sts/sts_service.go index cbd46aece..bf3ac2f82 100644 --- a/weed/iam/sts/sts_service.go +++ b/weed/iam/sts/sts_service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strconv" + "sync" "time" "github.com/golang-jwt/jwt/v5" @@ -79,6 +80,16 @@ type STSService struct { issuerToProvider map[string]providers.IdentityProvider // Efficient issuer-based provider lookup tokenGenerator *TokenGenerator trustPolicyValidator TrustPolicyValidator // Interface for trust policy validation + + // iamManagedOIDCMu guards iamManagedOIDCByIssuer. The map is the live view + // of providers persisted in the IAM-managed OIDCProviderStore; it is + // atomically replaced by SetIAMManagedOIDCProvidersByIssuer whenever the + // store changes (either via a local IAM API call or a metadata-subscribe + // event from a peer). Lookups consult this map first and fall back to the + // static-config issuerToProvider so admin-managed entries always take + // precedence over the bootstrap config. + iamManagedOIDCMu sync.RWMutex + iamManagedOIDCByIssuer map[string]providers.IdentityProvider } // GetTokenGenerator returns the token generator used by the STS service. @@ -424,6 +435,40 @@ func (s *STSService) GetProviders() map[string]providers.IdentityProvider { return s.providers } +// SetIAMManagedOIDCProvidersByIssuer atomically replaces the IAM-managed +// OIDC provider map. Pass nil or an empty map to clear all managed entries. +// The caller passes a fully-built map keyed by issuer URL; the STS service +// copies the reference and serves AssumeRoleWithWebIdentity lookups from it +// in preference to the static-config issuerToProvider map. +func (s *STSService) SetIAMManagedOIDCProvidersByIssuer(byIssuer map[string]providers.IdentityProvider) { + // Defensively copy so callers can keep mutating their map without affecting + // in-flight lookups. A nil input becomes an empty map for cheap reads. + cp := make(map[string]providers.IdentityProvider, len(byIssuer)) + for k, v := range byIssuer { + if k == "" || v == nil { + continue + } + cp[k] = v + } + s.iamManagedOIDCMu.Lock() + s.iamManagedOIDCByIssuer = cp + s.iamManagedOIDCMu.Unlock() +} + +// lookupOIDCProviderByIssuer returns the provider that should validate tokens +// from `issuer`, consulting the IAM-managed map first and falling back to the +// static-config map. Returns ok=false when no provider is registered. +func (s *STSService) lookupOIDCProviderByIssuer(issuer string) (providers.IdentityProvider, bool) { + s.iamManagedOIDCMu.RLock() + if p, ok := s.iamManagedOIDCByIssuer[issuer]; ok { + s.iamManagedOIDCMu.RUnlock() + return p, true + } + s.iamManagedOIDCMu.RUnlock() + p, ok := s.issuerToProvider[issuer] + return p, ok +} + // SetTrustPolicyValidator sets the trust policy validator for role assumption validation func (s *STSService) SetTrustPolicyValidator(validator TrustPolicyValidator) { s.trustPolicyValidator = validator @@ -715,8 +760,11 @@ func (s *STSService) validateWebIdentityToken(ctx context.Context, token string) return nil, nil, fmt.Errorf("web identity token must be a valid JWT token: %w", err) } - // Look up the specific provider for this issuer - provider, exists := s.issuerToProvider[issuer] + // Look up the specific provider for this issuer. IAM-managed records + // (admin-controlled, mutable at runtime) take precedence over the + // static-config map so an operator's CreateOpenIDConnectProvider call + // can shadow a bootstrap entry without requiring a restart. + provider, exists := s.lookupOIDCProviderByIssuer(issuer) if !exists { // SECURITY: If no provider is registered for this issuer, fail immediately // This prevents JWT tokens from being validated by unintended providers diff --git a/weed/s3api/auth_credentials_subscribe.go b/weed/s3api/auth_credentials_subscribe.go index 60c143d3a..03259aca4 100644 --- a/weed/s3api/auth_credentials_subscribe.go +++ b/weed/s3api/auth_credentials_subscribe.go @@ -1,6 +1,7 @@ package s3api import ( + "context" "strings" "time" @@ -12,6 +13,8 @@ import ( "github.com/seaweedfs/seaweedfs/weed/util" ) +const oidcProvidersDir = filer.IamConfigDirectory + "/oidc-providers" + func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, prefix string, directoriesToWatch []string) { processEventFn := func(resp *filer_pb.SubscribeMetadataResponse) error { @@ -30,12 +33,14 @@ func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, p // These handlers check for nil entries internally _ = s3a.onBucketMetadataChange(dir, message.OldEntry, message.NewEntry) _ = s3a.onIamConfigChange(dir, message.OldEntry, message.NewEntry) + _ = s3a.onOIDCProviderChange(dir, message.OldEntry, message.NewEntry) _ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, message.NewEntry) // For moves across directories, replay a delete event for the source directory if message.NewParentPath != "" && resp.Directory != message.NewParentPath { _ = s3a.onBucketMetadataChange(resp.Directory, message.OldEntry, nil) _ = s3a.onIamConfigChange(resp.Directory, message.OldEntry, nil) + _ = s3a.onOIDCProviderChange(resp.Directory, message.OldEntry, nil) _ = s3a.onCircuitBreakerConfigChange(resp.Directory, message.OldEntry, nil) } @@ -123,6 +128,33 @@ func (s3a *S3ApiServer) onIamConfigChange(dir string, oldEntry *filer_pb.Entry, return nil } +// onOIDCProviderChange refreshes the IAM-managed OIDC provider runtime view +// whenever the persisted store under /etc/iam/oidc-providers changes — both +// for mutations originated on this S3 server (the local IAM API also calls +// RefreshOIDCProvidersFromStore inline, but the subscribe path costs nothing +// extra) and for mutations originated on peer S3 servers, which this is the +// only mechanism to learn about. A single refresh covers create, update, +// delete, and rename because the store is small and a full reload is the +// safest way to reach a consistent view. +func (s3a *S3ApiServer) onOIDCProviderChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error { + if dir != oidcProvidersDir && !strings.HasPrefix(dir, oidcProvidersDir+"/") { + return nil + } + if s3a.iam == nil || s3a.iam.iamIntegration == nil { + return nil + } + s3iam, ok := s3a.iam.iamIntegration.(*S3IAMIntegration) + if !ok || s3iam.iamManager == nil { + return nil + } + if err := s3iam.iamManager.RefreshOIDCProvidersFromStore(context.Background()); err != nil { + glog.Warningf("OIDC provider refresh after %s change failed: %v", dir, err) + return err + } + glog.V(2).Infof("Refreshed IAM-managed OIDC providers after %s change", dir) + return nil +} + // onCircuitBreakerConfigChange handles circuit breaker config file changes (create, update, delete) func (s3a *S3ApiServer) onCircuitBreakerConfigChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error { if dir != s3_constants.CircuitBreakerConfigDir { diff --git a/weed/s3api/s3api_embedded_iam_oidc.go b/weed/s3api/s3api_embedded_iam_oidc.go index 467ba45d0..e3f478d06 100644 --- a/weed/s3api/s3api_embedded_iam_oidc.go +++ b/weed/s3api/s3api_embedded_iam_oidc.go @@ -12,12 +12,17 @@ import ( "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. +// OIDC provider IAM actions handled by this file. const ( - actionGetOpenIDConnectProvider = "GetOpenIDConnectProvider" - actionListOpenIDConnectProviders = "ListOpenIDConnectProviders" + actionGetOpenIDConnectProvider = "GetOpenIDConnectProvider" + actionListOpenIDConnectProviders = "ListOpenIDConnectProviders" + actionCreateOpenIDConnectProvider = "CreateOpenIDConnectProvider" + actionDeleteOpenIDConnectProvider = "DeleteOpenIDConnectProvider" + actionAddClientIDToOpenIDConnectProvider = "AddClientIDToOpenIDConnectProvider" + actionRemoveClientIDFromOpenIDConnectProvider = "RemoveClientIDFromOpenIDConnectProvider" + actionUpdateOpenIDConnectProviderThumbprint = "UpdateOpenIDConnectProviderThumbprint" + actionTagOpenIDConnectProvider = "TagOpenIDConnectProvider" + actionUntagOpenIDConnectProvider = "UntagOpenIDConnectProvider" ) // isOIDCProviderAction reports whether an action belongs to the OIDC provider @@ -26,7 +31,14 @@ const ( func isOIDCProviderAction(action string) bool { switch action { case actionGetOpenIDConnectProvider, - actionListOpenIDConnectProviders: + actionListOpenIDConnectProviders, + actionCreateOpenIDConnectProvider, + actionDeleteOpenIDConnectProvider, + actionAddClientIDToOpenIDConnectProvider, + actionRemoveClientIDFromOpenIDConnectProvider, + actionUpdateOpenIDConnectProviderThumbprint, + actionTagOpenIDConnectProvider, + actionUntagOpenIDConnectProvider: return true default: return false @@ -56,10 +68,221 @@ func (e *EmbeddedIamApi) dispatchOIDCProviderAction(ctx context.Context, values case actionGetOpenIDConnectProvider: resp, err := e.getOpenIDConnectProvider(ctx, mgr, values) return resp, err, true + case actionCreateOpenIDConnectProvider: + resp, err := e.createOpenIDConnectProvider(ctx, mgr, values) + return resp, err, true + case actionDeleteOpenIDConnectProvider: + resp, err := e.deleteOpenIDConnectProvider(ctx, mgr, values) + return resp, err, true + case actionAddClientIDToOpenIDConnectProvider: + resp, err := e.addClientIDToOpenIDConnectProvider(ctx, mgr, values) + return resp, err, true + case actionRemoveClientIDFromOpenIDConnectProvider: + resp, err := e.removeClientIDFromOpenIDConnectProvider(ctx, mgr, values) + return resp, err, true + case actionUpdateOpenIDConnectProviderThumbprint: + resp, err := e.updateOpenIDConnectProviderThumbprint(ctx, mgr, values) + return resp, err, true + case actionTagOpenIDConnectProvider: + resp, err := e.tagOpenIDConnectProvider(ctx, mgr, values) + return resp, err, true + case actionUntagOpenIDConnectProvider: + resp, err := e.untagOpenIDConnectProvider(ctx, mgr, values) + return resp, err, true } return nil, nil, false } +// extractMemberList collects all values from `Foo.member.=...` form +// parameters in order of N. AWS query-string conventions encode list inputs +// this way, so List/Add/Update/Tag actions all share this helper. +func extractMemberList(values url.Values, prefix string) []string { + out := []string{} + // Members are indexed from 1; iterate until a missing index breaks the run. + for i := 1; ; i++ { + key := fmt.Sprintf("%s.member.%d", prefix, i) + v := values.Get(key) + if v == "" { + break + } + out = append(out, v) + } + return out +} + +func (e *EmbeddedIamApi) createOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.CreateOpenIDConnectProviderResponse, *iamError) { + urlStr := strings.TrimSpace(values.Get("Url")) + if urlStr == "" { + return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("Url is required")} + } + clientIDs := extractMemberList(values, "ClientIDList") + if len(clientIDs) == 0 { + return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ClientIDList must contain at least one entry")} + } + thumbprints := extractMemberList(values, "ThumbprintList") + tags := extractTags(values) + + accountID := mgr.GetSTSService().Config.AccountId + arn, err := integration.DeriveOIDCProviderARN(accountID, urlStr) + if err != nil { + return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err} + } + + rec := &integration.OIDCProviderRecord{ + AccountID: accountID, + ARN: arn, + URL: urlStr, + ClientIDs: clientIDs, + Thumbprints: thumbprints, + Tags: tags, + } + if err := mgr.CreateOIDCProvider(ctx, rec); err != nil { + if errors.Is(err, integration.ErrOIDCProviderAlreadyExists) { + return nil, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: err} + } + return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err} + } + + resp := &iamlib.CreateOpenIDConnectProviderResponse{} + resp.CreateOpenIDConnectProviderResult.OpenIDConnectProviderArn = rec.ARN + if len(rec.Tags) > 0 { + out := make([]*iamlib.IAMTag, 0, len(rec.Tags)) + for k, v := range rec.Tags { + out = append(out, &iamlib.IAMTag{Key: k, Value: v}) + } + resp.CreateOpenIDConnectProviderResult.Tags = out + } + return resp, nil +} + +func (e *EmbeddedIamApi) deleteOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.DeleteOpenIDConnectProviderResponse, *iamError) { + arn, iamErr := requireProviderArn(values) + if iamErr != nil { + return nil, iamErr + } + if err := mgr.DeleteOIDCProvider(ctx, arn); err != nil { + return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + return &iamlib.DeleteOpenIDConnectProviderResponse{}, nil +} + +func (e *EmbeddedIamApi) addClientIDToOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.AddClientIDToOpenIDConnectProviderResponse, *iamError) { + arn, iamErr := requireProviderArn(values) + if iamErr != nil { + return nil, iamErr + } + clientID := strings.TrimSpace(values.Get("ClientID")) + if clientID == "" { + return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ClientID is required")} + } + if err := mgr.AddClientIDToOIDCProvider(ctx, arn, clientID); err != nil { + if errors.Is(err, integration.ErrOIDCProviderNotFound) { + return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err} + } + return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + return &iamlib.AddClientIDToOpenIDConnectProviderResponse{}, nil +} + +func (e *EmbeddedIamApi) removeClientIDFromOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.RemoveClientIDFromOpenIDConnectProviderResponse, *iamError) { + arn, iamErr := requireProviderArn(values) + if iamErr != nil { + return nil, iamErr + } + clientID := strings.TrimSpace(values.Get("ClientID")) + if clientID == "" { + return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ClientID is required")} + } + if err := mgr.RemoveClientIDFromOIDCProvider(ctx, arn, clientID); err != nil { + if errors.Is(err, integration.ErrOIDCProviderNotFound) { + return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err} + } + return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + return &iamlib.RemoveClientIDFromOpenIDConnectProviderResponse{}, nil +} + +func (e *EmbeddedIamApi) updateOpenIDConnectProviderThumbprint(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.UpdateOpenIDConnectProviderThumbprintResponse, *iamError) { + arn, iamErr := requireProviderArn(values) + if iamErr != nil { + return nil, iamErr + } + thumbprints := extractMemberList(values, "ThumbprintList") + if len(thumbprints) == 0 { + return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ThumbprintList must contain at least one entry")} + } + if err := mgr.UpdateOIDCProviderThumbprints(ctx, arn, thumbprints); err != nil { + if errors.Is(err, integration.ErrOIDCProviderNotFound) { + return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err} + } + return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err} + } + return &iamlib.UpdateOpenIDConnectProviderThumbprintResponse{}, nil +} + +func (e *EmbeddedIamApi) tagOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.TagOpenIDConnectProviderResponse, *iamError) { + arn, iamErr := requireProviderArn(values) + if iamErr != nil { + return nil, iamErr + } + tags := extractTags(values) + if len(tags) == 0 { + return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("Tags must contain at least one Key/Value pair")} + } + if err := mgr.TagOIDCProvider(ctx, arn, tags); err != nil { + if errors.Is(err, integration.ErrOIDCProviderNotFound) { + return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err} + } + return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + return &iamlib.TagOpenIDConnectProviderResponse{}, nil +} + +func (e *EmbeddedIamApi) untagOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.UntagOpenIDConnectProviderResponse, *iamError) { + arn, iamErr := requireProviderArn(values) + if iamErr != nil { + return nil, iamErr + } + keys := extractMemberList(values, "TagKeys") + if len(keys) == 0 { + return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("TagKeys must contain at least one entry")} + } + if err := mgr.UntagOIDCProvider(ctx, arn, keys); err != nil { + if errors.Is(err, integration.ErrOIDCProviderNotFound) { + return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err} + } + return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + return &iamlib.UntagOpenIDConnectProviderResponse{}, nil +} + +// extractTags walks the AWS IAM "Tags.member.N.Key / Tags.member.N.Value" +// query-string convention and returns the parsed map. Returns nil (not an +// empty map) when no tags are present so the caller can distinguish +// "untouched" from "empty". +func extractTags(values url.Values) map[string]string { + var out map[string]string + for i := 1; ; i++ { + k := values.Get(fmt.Sprintf("Tags.member.%d.Key", i)) + if k == "" { + break + } + if out == nil { + out = make(map[string]string) + } + out[k] = values.Get(fmt.Sprintf("Tags.member.%d.Value", i)) + } + return out +} + +func requireProviderArn(values url.Values) (string, *iamError) { + arn := strings.TrimSpace(values.Get("OpenIDConnectProviderArn")) + if arn == "" { + return "", &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("OpenIDConnectProviderArn is required")} + } + return arn, nil +} + func (e *EmbeddedIamApi) oidcIAMManager() *integration.IAMManager { if e.iam == nil || e.iam.iamIntegration == nil { return nil diff --git a/weed/s3api/s3api_iam_oidc_test.go b/weed/s3api/s3api_iam_oidc_test.go index 653c38939..cd503672c 100644 --- a/weed/s3api/s3api_iam_oidc_test.go +++ b/weed/s3api/s3api_iam_oidc_test.go @@ -149,3 +149,248 @@ func TestReadOnlyAllowsOIDCList(t *testing.T) { t.Fatalf("read-only mode should allow ListOpenIDConnectProviders: %v", iamErr.Error) } } + +func TestReadOnlyDeniesOIDCMutations(t *testing.T) { + api, _ := newOIDCTestAPI(t) + api.readOnly = true + mutations := []string{ + actionCreateOpenIDConnectProvider, + actionDeleteOpenIDConnectProvider, + actionAddClientIDToOpenIDConnectProvider, + actionRemoveClientIDFromOpenIDConnectProvider, + actionUpdateOpenIDConnectProviderThumbprint, + actionTagOpenIDConnectProvider, + actionUntagOpenIDConnectProvider, + } + for _, action := range mutations { + t.Run(action, func(t *testing.T) { + values := url.Values{} + values.Set("Action", action) + _, iamErr := api.ExecuteAction(context.Background(), values, true, "ro-deny") + if iamErr == nil { + t.Fatalf("expected denial for %s in read-only mode", action) + } + }) + } +} + +func TestCreateOpenIDConnectProvider(t *testing.T) { + api, _ := newOIDCTestAPI(t) + values := url.Values{} + values.Set("Action", actionCreateOpenIDConnectProvider) + values.Set("Url", "https://auth.example.com") + values.Set("ClientIDList.member.1", "alpha") + values.Set("ClientIDList.member.2", "beta") + values.Set("ThumbprintList.member.1", "9e99a48a9960b14926bb7f3b02e22da2b0ab7280") + values.Set("Tags.member.1.Key", "team") + values.Set("Tags.member.1.Value", "infra") + + resp, iamErr := api.ExecuteAction(context.Background(), values, true, "create-1") + if iamErr != nil { + t.Fatalf("ExecuteAction: %v", iamErr.Error) + } + createResp, ok := resp.(*iamlib.CreateOpenIDConnectProviderResponse) + if !ok { + t.Fatalf("unexpected response type %T", resp) + } + wantArn := "arn:aws:iam::111122223333:oidc-provider/auth.example.com" + if createResp.CreateOpenIDConnectProviderResult.OpenIDConnectProviderArn != wantArn { + t.Fatalf("ARN mismatch: got=%s want=%s", + createResp.CreateOpenIDConnectProviderResult.OpenIDConnectProviderArn, wantArn) + } + + // Get the new provider back to confirm persistence and clientId list. + getValues := url.Values{} + getValues.Set("Action", actionGetOpenIDConnectProvider) + getValues.Set("OpenIDConnectProviderArn", wantArn) + getResp, iamErr := api.ExecuteAction(context.Background(), getValues, true, "get-after-create") + if iamErr != nil { + t.Fatalf("Get after create: %v", iamErr.Error) + } + gr := getResp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult + if len(gr.ClientIDList) != 2 || gr.ClientIDList[0] != "alpha" || gr.ClientIDList[1] != "beta" { + t.Fatalf("ClientIDList persisted incorrectly: %v", gr.ClientIDList) + } + if len(gr.ThumbprintList) != 1 || gr.ThumbprintList[0] != "9e99a48a9960b14926bb7f3b02e22da2b0ab7280" { + t.Fatalf("ThumbprintList persisted incorrectly: %v", gr.ThumbprintList) + } + if len(gr.Tags) != 1 || gr.Tags[0].Key != "team" || gr.Tags[0].Value != "infra" { + t.Fatalf("Tags persisted incorrectly: %v", gr.Tags) + } +} + +func TestCreateOpenIDConnectProviderRejectsBadInput(t *testing.T) { + api, _ := newOIDCTestAPI(t) + + t.Run("missing Url", func(t *testing.T) { + values := url.Values{} + values.Set("Action", actionCreateOpenIDConnectProvider) + values.Set("ClientIDList.member.1", "x") + _, iamErr := api.ExecuteAction(context.Background(), values, true, "") + if iamErr == nil || iamErr.Code != "InvalidInput" { + t.Fatalf("expected InvalidInput, got %v", iamErr) + } + }) + + t.Run("missing ClientIDList", func(t *testing.T) { + values := url.Values{} + values.Set("Action", actionCreateOpenIDConnectProvider) + values.Set("Url", "https://auth.example.com") + _, iamErr := api.ExecuteAction(context.Background(), values, true, "") + if iamErr == nil || iamErr.Code != "InvalidInput" { + t.Fatalf("expected InvalidInput, got %v", iamErr) + } + }) + + t.Run("invalid thumbprint", func(t *testing.T) { + values := url.Values{} + values.Set("Action", actionCreateOpenIDConnectProvider) + values.Set("Url", "https://auth.example.com") + values.Set("ClientIDList.member.1", "x") + values.Set("ThumbprintList.member.1", "not-a-sha1") + _, iamErr := api.ExecuteAction(context.Background(), values, true, "") + if iamErr == nil || iamErr.Code != "InvalidInput" { + t.Fatalf("expected InvalidInput, got %v", iamErr) + } + }) +} + +func TestCreateOpenIDConnectProviderConflict(t *testing.T) { + api, _ := newOIDCTestAPI(t) + values := url.Values{} + values.Set("Action", actionCreateOpenIDConnectProvider) + values.Set("Url", "https://accounts.google.com") // already mirrored from static config + values.Set("ClientIDList.member.1", "x") + + _, iamErr := api.ExecuteAction(context.Background(), values, true, "") + if iamErr == nil { + t.Fatal("expected conflict for duplicate provider") + } + if iamErr.Code != "EntityAlreadyExists" { + t.Fatalf("expected EntityAlreadyExists, got %s", iamErr.Code) + } +} + +func TestDeleteOpenIDConnectProvider(t *testing.T) { + api, _ := newOIDCTestAPI(t) + arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com" + + values := url.Values{} + values.Set("Action", actionDeleteOpenIDConnectProvider) + values.Set("OpenIDConnectProviderArn", arn) + + if _, iamErr := api.ExecuteAction(context.Background(), values, true, ""); iamErr != nil { + t.Fatalf("delete: %v", iamErr.Error) + } + // Verify gone. + getValues := url.Values{} + getValues.Set("Action", actionGetOpenIDConnectProvider) + getValues.Set("OpenIDConnectProviderArn", arn) + if _, iamErr := api.ExecuteAction(context.Background(), getValues, true, ""); iamErr == nil { + t.Fatal("expected not-found after delete") + } + // Idempotent. + if _, iamErr := api.ExecuteAction(context.Background(), values, true, ""); iamErr != nil { + t.Fatalf("idempotent delete failed: %v", iamErr.Error) + } +} + +func TestAddRemoveClientID(t *testing.T) { + api, _ := newOIDCTestAPI(t) + arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com" + + add := url.Values{} + add.Set("Action", actionAddClientIDToOpenIDConnectProvider) + add.Set("OpenIDConnectProviderArn", arn) + add.Set("ClientID", "extra-client") + if _, iamErr := api.ExecuteAction(context.Background(), add, true, ""); iamErr != nil { + t.Fatalf("add: %v", iamErr.Error) + } + + getValues := url.Values{} + getValues.Set("Action", actionGetOpenIDConnectProvider) + getValues.Set("OpenIDConnectProviderArn", arn) + resp, iamErr := api.ExecuteAction(context.Background(), getValues, true, "") + if iamErr != nil { + t.Fatalf("get: %v", iamErr.Error) + } + got := resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult.ClientIDList + if len(got) != 2 { + t.Fatalf("expected 2 clients after add, got %v", got) + } + + // Idempotent add. + if _, iamErr := api.ExecuteAction(context.Background(), add, true, ""); iamErr != nil { + t.Fatalf("idempotent add: %v", iamErr.Error) + } + + // Remove. + rm := url.Values{} + rm.Set("Action", actionRemoveClientIDFromOpenIDConnectProvider) + rm.Set("OpenIDConnectProviderArn", arn) + rm.Set("ClientID", "extra-client") + if _, iamErr := api.ExecuteAction(context.Background(), rm, true, ""); iamErr != nil { + t.Fatalf("remove: %v", iamErr.Error) + } + resp, _ = api.ExecuteAction(context.Background(), getValues, true, "") + got = resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult.ClientIDList + if len(got) != 1 || got[0] != "client-google" { + t.Fatalf("unexpected clients after remove: %v", got) + } + + // Removing a missing client is a no-op. + if _, iamErr := api.ExecuteAction(context.Background(), rm, true, ""); iamErr != nil { + t.Fatalf("idempotent remove: %v", iamErr.Error) + } +} + +func TestUpdateThumbprintAndTags(t *testing.T) { + api, _ := newOIDCTestAPI(t) + arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com" + + upd := url.Values{} + upd.Set("Action", actionUpdateOpenIDConnectProviderThumbprint) + upd.Set("OpenIDConnectProviderArn", arn) + upd.Set("ThumbprintList.member.1", "0000000000000000000000000000000000000000") + upd.Set("ThumbprintList.member.2", "1111111111111111111111111111111111111111") + if _, iamErr := api.ExecuteAction(context.Background(), upd, true, ""); iamErr != nil { + t.Fatalf("update thumbprints: %v", iamErr.Error) + } + + tag := url.Values{} + tag.Set("Action", actionTagOpenIDConnectProvider) + tag.Set("OpenIDConnectProviderArn", arn) + tag.Set("Tags.member.1.Key", "owner") + tag.Set("Tags.member.1.Value", "platform") + if _, iamErr := api.ExecuteAction(context.Background(), tag, true, ""); iamErr != nil { + t.Fatalf("tag: %v", iamErr.Error) + } + + get := url.Values{} + get.Set("Action", actionGetOpenIDConnectProvider) + get.Set("OpenIDConnectProviderArn", arn) + resp, iamErr := api.ExecuteAction(context.Background(), get, true, "") + if iamErr != nil { + t.Fatalf("get: %v", iamErr.Error) + } + gr := resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult + if len(gr.ThumbprintList) != 2 { + t.Fatalf("ThumbprintList persisted incorrectly: %v", gr.ThumbprintList) + } + if len(gr.Tags) != 1 || gr.Tags[0].Key != "owner" { + t.Fatalf("Tags persisted incorrectly: %v", gr.Tags) + } + + untag := url.Values{} + untag.Set("Action", actionUntagOpenIDConnectProvider) + untag.Set("OpenIDConnectProviderArn", arn) + untag.Set("TagKeys.member.1", "owner") + if _, iamErr := api.ExecuteAction(context.Background(), untag, true, ""); iamErr != nil { + t.Fatalf("untag: %v", iamErr.Error) + } + resp, _ = api.ExecuteAction(context.Background(), get, true, "") + gr = resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult + if len(gr.Tags) != 0 { + t.Fatalf("Tags should be empty after untag, got: %v", gr.Tags) + } +} diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index 5a49272f1..e6a2cb064 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -373,6 +373,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl filer.IamConfigDirectory + "/policies", filer.IamConfigDirectory + "/service_accounts", filer.IamConfigDirectory + "/groups", + filer.IamConfigDirectory + "/oidc-providers", }) // Start bucket size metrics collection in background