mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-11 09:00:45 +02:00
* feat(iam): OIDC provider mutations + multi-client + TLS thumbprints - Mutating IAM actions: CreateOpenIDConnectProvider, DeleteOpenIDConnectProvider, AddClientIDToOpenIDConnectProvider, RemoveClientIDFromOpenIDConnectProvider, UpdateOpenIDConnectProviderThumbprint, TagOpenIDConnectProvider, UntagOpenIDConnectProvider. Each enforces AWS-shape input bounds and the read-only mode rejects all mutations. - Multiple client_ids per provider in OIDCConfig (clientIds list, plural) with full backward compatibility — singular clientId still works and is merged into the audience allowlist. Provider factory accepts both. - AWS-compatible TLS thumbprint pinning: when OIDCConfig.Thumbprints is non-empty, JWKS fetches enforce that the negotiated TLS chain contains a certificate whose SHA-1 hex matches the allowlist. Empty list keeps the existing system-trust path. * fix(iam): factor Tags.member.N parser into a helper CreateOpenIDConnectProvider and TagOpenIDConnectProvider were both walking the AWS Tags.member.N.Key / Tags.member.N.Value query-string convention with copy-pasted loops. Factor into extractTags so the parsing rules and the "no tags present" semantics live in one place. Addresses gemini medium review on PR #9320. * fix(iam): sentinel errors for OIDC provider not-found / already-exists The s3api dispatcher was using strings.Contains(err.Error(), "not found") and "already exists" to map IAM-manager errors back to AWS error codes. Substring matching on a formatted message couples the API error code to the exact wording of the upstream message — touching the message silently changes the IAM API contract. Define ErrOIDCProviderNotFound and ErrOIDCProviderAlreadyExists in the integration package, fmt.Errorf("%w: ...") them at the four return sites in iam_manager.go and oidc_provider_store.go, and use errors.Is at the s3api call sites. Same control flow, no string-match fragility. Addresses gemini medium review on PR #9320. * fix(iam): surface non-NotFound errors from CreateOIDCProvider lookup Previously CreateOIDCProvider only treated GetProviderByARN's success path as "exists" and silently fell through on any error, including transient backend failures. That hid real problems and still attempted a write. Distinguish ErrOIDCProviderNotFound (the only "safe to create" case) from other errors so we don't mask filer outages or partition issues. * fix(iam): enforce 100-client-ID cap on AddClientIDToOIDCProvider CreateOIDCProvider and the implicit update path through validateOIDC- ProviderRecord both reject lists with more than 100 client IDs, but AddClientIDToOIDCProvider could grow the list past that bound one ID at a time. Refuse the add when the list is already at the cap so the invariant holds across every mutation entry point. * feat(iam): IAM-managed OIDC provider live view in STS service Add a separate, mutex-guarded map of admin-managed OIDC providers on the STS service. The map can be atomically replaced via SetIAMManagedOIDCProvidersByIssuer; AssumeRoleWithWebIdentity lookups consult it first and fall back to the existing static-config map, so records persisted through the IAM API can shadow bootstrap entries without a restart. This is the runtime hook the IAM API and the metadata-subscribe path will both call when the OIDCProviderStore changes (next two commits). * feat(iam): refresh STS service runtime view after OIDC mutations Add IAMManager.RefreshOIDCProvidersFromStore: lists every persisted OIDCProviderRecord, builds a runtime OIDCProvider for each, and atomically publishes the issuer-keyed map into the STS service. Each mutating IAM API call (Create / Delete / AddClientID / RemoveClientID / UpdateThumbprints) now triggers this refresh inline so the local instance picks up the change without waiting for a metadata-subscribe round trip. Tag mutations skip the refresh because tags do not affect token validation. Refresh failures only log; the persisted write has already succeeded by that point, so a transient list error must not surface to the API caller. The peer-instance update path (filer metadata subscription) is added in a follow-up commit. * feat(iam): subscribe to OIDC provider changes on the filer Watch /etc/iam/oidc-providers under the existing s3 metadata-subscribe loop and call RefreshOIDCProvidersFromStore on any create / update / delete / rename. This is the cross-instance update path: S3 server A writes via the IAM API, the filer fans out the metadata change, and S3 servers B..N pick up the new runtime view without a restart. Mirrors the existing onIamConfigChange / onCircuitBreakerConfigChange pattern. The handler short-circuits when the path is unrelated, and when no IAMManager is wired in (static-only configurations).
288 lines
11 KiB
Go
288 lines
11 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"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 {
|
|
|
|
message := resp.EventNotification
|
|
|
|
// For rename/move operations, NewParentPath contains the destination directory.
|
|
// We process both source and destination dirs so moves out of watched
|
|
// directories (e.g., IAM config dirs) are not missed.
|
|
dir := resp.Directory
|
|
if message.NewParentPath != "" {
|
|
dir = message.NewParentPath
|
|
}
|
|
|
|
// Handle all metadata changes (create, update, delete, rename)
|
|
// 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)
|
|
}
|
|
|
|
// For same-directory renames, replay a delete event for the old name
|
|
// so handlers can clean up stale state (e.g., old bucket names)
|
|
if message.OldEntry != nil && message.NewEntry != nil &&
|
|
(message.NewParentPath == "" || message.NewParentPath == resp.Directory) &&
|
|
message.OldEntry.Name != message.NewEntry.Name {
|
|
_ = s3a.onBucketMetadataChange(dir, message.OldEntry, nil)
|
|
_ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, nil)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
metadataFollowOption := &pb.MetadataFollowOption{
|
|
ClientName: clientName,
|
|
ClientId: s3a.randomClientId,
|
|
ClientEpoch: 1,
|
|
SelfSignature: 0,
|
|
PathPrefix: prefix,
|
|
AdditionalPathPrefixes: nil,
|
|
DirectoriesToWatch: directoriesToWatch,
|
|
StartTsNs: lastTsNs,
|
|
StopTsNs: 0,
|
|
EventErrorType: pb.FatalOnError,
|
|
}
|
|
util.RetryUntil("followIamChanges", func() error {
|
|
metadataFollowOption.ClientEpoch++
|
|
return pb.WithFilerClientFollowMetadata(s3a, metadataFollowOption, processEventFn)
|
|
}, func(err error) bool {
|
|
glog.V(1).Infof("iam follow metadata changes: %v", err)
|
|
return true
|
|
})
|
|
}
|
|
|
|
// onIamConfigChange handles IAM config file changes (create, update, delete)
|
|
func (s3a *S3ApiServer) onIamConfigChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
|
|
if s3a.iam != nil && s3a.iam.IsStaticConfig() {
|
|
glog.V(1).Infof("Skipping IAM config update for static configuration")
|
|
return nil
|
|
}
|
|
if s3a.iam == nil {
|
|
return nil
|
|
}
|
|
|
|
reloadIamConfig := func(reason string) error {
|
|
glog.V(1).Infof("IAM change detected in %s, reloading configuration", reason)
|
|
if err := s3a.iam.LoadS3ApiConfigurationFromCredentialManager(); err != nil {
|
|
glog.Errorf("failed to reload IAM configuration after change in %s: %v", reason, err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 1. Handle traditional single identity.json file
|
|
if dir == filer.IamConfigDirectory {
|
|
// Handle create/update/delete events on legacy identity.json.
|
|
// During migration this file is renamed, which emits a delete event.
|
|
// Always reload from the credential manager so we keep the migrated identities.
|
|
if (oldEntry != nil && oldEntry.Name == filer.IamIdentityFile) ||
|
|
(newEntry != nil && newEntry.Name == filer.IamIdentityFile) {
|
|
if err := reloadIamConfig(dir + "/" + filer.IamIdentityFile); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 2. Handle multiple-file identities and policies
|
|
// Watch /etc/iam/{identities,policies,service_accounts}
|
|
isIdentityDir := dir == filer.IamConfigDirectory+"/identities" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/identities/")
|
|
isPolicyDir := dir == filer.IamConfigDirectory+"/policies" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/policies/")
|
|
isServiceAccountDir := dir == filer.IamConfigDirectory+"/service_accounts" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/service_accounts/")
|
|
isGroupDir := dir == filer.IamConfigDirectory+"/groups" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/groups/")
|
|
|
|
if isIdentityDir || isPolicyDir || isServiceAccountDir || isGroupDir {
|
|
// For multiple-file mode, any change in these directories should trigger a full reload
|
|
// from the credential manager (which handles the details of loading from multiple files).
|
|
if err := reloadIamConfig(dir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return nil
|
|
}
|
|
|
|
// Handle deletion: reset to empty config
|
|
if newEntry == nil && oldEntry != nil && oldEntry.Name == s3_constants.CircuitBreakerConfigFile {
|
|
glog.V(1).Infof("Circuit breaker config file deleted, resetting to defaults")
|
|
if err := s3a.cb.LoadS3ApiConfigurationFromBytes([]byte{}); err != nil {
|
|
glog.Warningf("failed to reset circuit breaker config on deletion: %v", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Handle create/update
|
|
if newEntry != nil && newEntry.Name == s3_constants.CircuitBreakerConfigFile {
|
|
if err := s3a.cb.LoadS3ApiConfigurationFromBytes(newEntry.Content); err != nil {
|
|
return err
|
|
}
|
|
glog.V(1).Infof("updated %s/%s", dir, newEntry.Name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reload bucket metadata
|
|
func (s3a *S3ApiServer) onBucketMetadataChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
|
|
if dir == s3a.option.BucketsPath {
|
|
if newEntry != nil {
|
|
// Update bucket registry (existing functionality)
|
|
s3a.bucketRegistry.LoadBucketMetadata(newEntry)
|
|
glog.V(1).Infof("updated bucketMetadata %s/%s", dir, newEntry.Name)
|
|
|
|
// Update bucket configuration cache with new entry
|
|
s3a.updateBucketConfigCacheFromEntry(newEntry)
|
|
} else if oldEntry != nil {
|
|
// Remove from bucket registry (existing functionality)
|
|
s3a.bucketRegistry.RemoveBucketMetadata(oldEntry)
|
|
glog.V(1).Infof("remove bucketMetadata %s/%s", dir, oldEntry.Name)
|
|
|
|
// Remove from bucket configuration cache
|
|
s3a.invalidateBucketConfigCache(oldEntry.Name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// updateBucketConfigCacheFromEntry updates the bucket config cache when a bucket entry changes
|
|
func (s3a *S3ApiServer) updateBucketConfigCacheFromEntry(entry *filer_pb.Entry) {
|
|
if s3a.bucketConfigCache == nil {
|
|
return
|
|
}
|
|
|
|
bucket := entry.Name
|
|
|
|
glog.V(3).Infof("updateBucketConfigCacheFromEntry: called for bucket %s, ExtObjectLockEnabledKey=%s",
|
|
bucket, string(entry.Extended[s3_constants.ExtObjectLockEnabledKey]))
|
|
|
|
// Create new bucket config from the entry
|
|
config := &BucketConfig{
|
|
Name: bucket,
|
|
Entry: entry,
|
|
IsPublicRead: false, // Explicitly default to false for private buckets
|
|
}
|
|
|
|
// Extract configuration from extended attributes
|
|
if entry.Extended != nil {
|
|
if versioning, exists := entry.Extended[s3_constants.ExtVersioningKey]; exists {
|
|
config.Versioning = string(versioning)
|
|
}
|
|
if ownership, exists := entry.Extended[s3_constants.ExtOwnershipKey]; exists {
|
|
config.Ownership = string(ownership)
|
|
}
|
|
if acl, exists := entry.Extended[s3_constants.ExtAmzAclKey]; exists {
|
|
config.ACL = acl
|
|
// Parse ACL and cache public-read status
|
|
config.IsPublicRead = parseAndCachePublicReadStatus(acl)
|
|
} else {
|
|
// No ACL means private bucket
|
|
config.IsPublicRead = false
|
|
}
|
|
if owner, exists := entry.Extended[s3_constants.ExtAmzOwnerKey]; exists {
|
|
config.Owner = string(owner)
|
|
}
|
|
// Parse Object Lock configuration if present
|
|
if objectLockConfig, found := LoadObjectLockConfigurationFromExtended(entry); found {
|
|
config.ObjectLockConfig = objectLockConfig
|
|
glog.V(2).Infof("updateBucketConfigCacheFromEntry: cached Object Lock configuration for bucket %s: %+v", bucket, objectLockConfig)
|
|
} else {
|
|
glog.V(3).Infof("updateBucketConfigCacheFromEntry: no Object Lock configuration found for bucket %s", bucket)
|
|
}
|
|
|
|
// Load bucket policy if present (for performance optimization)
|
|
config.BucketPolicy = loadBucketPolicyFromExtended(entry, bucket)
|
|
}
|
|
|
|
// Sync bucket policy to the policy engine for evaluation
|
|
s3a.syncBucketPolicyToEngine(bucket, config.BucketPolicy)
|
|
|
|
// Parse CORS configuration directly from the subscription entry's Content field.
|
|
// This avoids a separate RPC call that could return stale data when racing with
|
|
// concurrent metadata updates (e.g., PutBucketCors clearing the cache while this
|
|
// handler is still processing an older event).
|
|
config.CORS = parseCORSFromEntryContent(entry.Content)
|
|
if config.CORS != nil {
|
|
glog.V(2).Infof("updateBucketConfigCacheFromEntry: parsed CORS config for bucket %s from entry content", bucket)
|
|
}
|
|
|
|
// Update timestamp
|
|
config.LastModified = time.Now()
|
|
|
|
// Update cache
|
|
glog.V(3).Infof("updateBucketConfigCacheFromEntry: updating cache for bucket %s, ObjectLockConfig=%+v", bucket, config.ObjectLockConfig)
|
|
s3a.bucketConfigCache.Set(bucket, config)
|
|
// Remove from negative cache since bucket now exists
|
|
// This is important for buckets created via weed shell or other external means
|
|
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
|
|
}
|
|
|
|
// invalidateBucketConfigCache removes a bucket from the configuration cache
|
|
func (s3a *S3ApiServer) invalidateBucketConfigCache(bucket string) {
|
|
if s3a.bucketConfigCache == nil {
|
|
return
|
|
}
|
|
|
|
s3a.bucketConfigCache.Remove(bucket)
|
|
s3a.bucketConfigCache.RemoveNegativeCache(bucket) // Also remove from negative cache
|
|
glog.V(2).Infof("invalidateBucketConfigCache: removed bucket %s from cache", bucket)
|
|
}
|