Files
seaweedfs/weed/s3api/auth_credentials_subscribe.go
T
Chris Lu 5ff49909a0 fix(s3api/iam): avoid transient AccessDenied from full reloads on single IAM file changes (#11271)
* fix(s3api/iam): fail config snapshot on empty or malformed IAM files

A full IAM reload reads every identity/policy/service-account/group file
from the filer. When an external secrets tool rewrites a file, a reload
that reads it mid-rewrite sees empty or partially-written content. The
identity, policy and service-account loaders silently skipped such files
(``continue``), so the snapshot was missing entries that still existed
on disk. The atomic swap then installed an incomplete identity set while
``isAuthEnabled`` stayed on, denying unrelated clients mid-reload
(#11259).

The group loader and the read-error paths already fail the snapshot in
this situation (a skipped entry reads as deleted). Apply the same
behavior to empty content and unmarshal failures across the identity,
policy, service-account and group loaders, so a transient mid-rewrite
fails the reload (preserving the last known-good state) instead of
silently dropping entries.

* fix(s3api/iam): coalesce burst IAM config reloads through the reload queue

onIamConfigChange did a full synchronous reload for every identity/policy
file change event. When several independently-refreshing credentials
rewrite their files within the same second, that produced a burst of
dozens of back-to-back full reloads, each reading the whole store and
widening the window where a mid-rewrite file is observed (#11259).

Route every IAM config change through the existing coalescing reload
queue (scheduleReload/reloadRetryLoop) instead. A burst of N events now
collapses into a single reload (plus one tail reload for events that
arrived while one was in flight). scheduleReload gains a reason argument
for the existing log line; the reloadRetryLoop already retries failed
reloads, so the per-event failure handoff is no longer needed.

Tests that asserted on the synchronous reload now wire up the queue
(centralized in newTestS3ApiServerWithMemoryIAM) and poll via
waitForIdentity/waitForIdentityGone. Adds TestOnIamConfigChangeCoalescesBurstReloads
showing 50 events coalesce into <=3 reloads.

* fix(s3api/iam): skip non-JSON auxiliary files before failing IAM snapshot

Per review: the multi-file loaders unmarshal every entry in an IAM
directory, so a non-JSON auxiliary file (README, .DS_Store, a migration
backup such as identity.json.old) would hit the new empty/malformed
errors and reject the whole snapshot, blocking all later IAM reloads.

Only *.json files are IAM objects (SeaweedFS writes identities,
policies, service accounts and groups as <name>.json, and other call
sites already gate on the .json suffix). Skip non-.json entries at the
top of each loader loop, before reading content, so auxiliary files are
ignored while empty/malformed .json files still fail the snapshot.

Adds TestLoadConfigurationIgnoresNonJsonAuxiliaryFiles.

* fix(s3api/iam): reject IAM files with empty identifiers and skip aux in listing

Per review:

- ListPolicyNames listed every regular entry in the policies directory as a
  policy name, including non-JSON auxiliary files, but GetPolicy cannot
  retrieve them. Apply the same .json suffix filter used by the loader so
  the list only exposes retrievable policies.

- json.Unmarshal accepts `{}` and unknown fields. The identity and group
  loaders merge by the decoded Name (not the file name), so a `{}` file
  could install an empty-key record and displace a real one; the
  service-account loader accepted an empty Id. Validate Identity.Name,
  Group.Name and ServiceAccount.Id (via validateServiceAccountId) after
  unmarshal and fail the snapshot on empty identifiers.

Adds TestFilerEtcStoreListPolicyNamesSkipsNonJsonAuxiliary and
empty-identifier regression tests for identity, group and service-account
files.
2026-09-11 10:42:19 -07:00

228 lines
9.1 KiB
Go

package s3api
import (
"context"
"strings"
"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).
// It reloads even with a static -config file: the merge protects the file's
// identities, and the filer->s3 push alone is best-effort.
func (s3a *S3ApiServer) onIamConfigChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
if s3a.iam == nil {
return nil
}
// Coalesce bursts through the reload queue instead of one full synchronous
// reload per event: independently-refreshing credentials can rewrite several
// files within the same second.
// 1. Handle traditional single identity.json file
if dir == filer.IamConfigDirectory {
if (oldEntry != nil && oldEntry.Name == filer.IamIdentityFile) ||
(newEntry != nil && newEntry.Name == filer.IamIdentityFile) {
s3a.iam.scheduleReload(dir + "/" + filer.IamIdentityFile)
}
return nil
}
// 2. Handle multiple-file identities, policies, service accounts and groups
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 {
s3a.iam.scheduleReload(dir)
}
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 {
s3a.maintainBucketOwnerIndex(oldEntry, newEntry)
s3a.mirrorBucketPolicyToIAM(oldEntry, newEntry)
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
// 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)
// Only refresh buckets already resident in the cache; cold buckets
// lazy-load on first access so the cache holds this gateway's working
// set, not every bucket in the cluster.
if !s3a.bucketConfigCache.Contains(bucket) {
return
}
// newBucketConfigFromEntry is the single source of truth for mapping
// Entry.Extended → cached fields (incl. LifecycleTTL), so a meta-log
// Put/DeleteBucketLifecycle here can't leave a stale resolver in cache.
config := s3a.newBucketConfigFromEntry(bucket, entry)
glog.V(3).Infof("updateBucketConfigCacheFromEntry: refreshing cache for bucket %s, ObjectLockConfig=%+v", bucket, config.ObjectLockConfig)
s3a.bucketConfigCache.Set(bucket, config)
}
// 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)
}