From b04c93f9a9c778971ff1a6c30698f4a54d7dc625 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 8 Jan 2026 18:58:34 -0800 Subject: [PATCH] refactor(s3api): implement merge-based static config with immutable identities Static identities from config file are now immutable and protected from dynamic updates. Dynamic identities (from admin panel) can be added and updated without affecting static entries. - Track identity names loaded from static config file - Implement merge logic that preserves static identities - Allow dynamic identities to be added or updated - Remove blanket block on config file updates --- weed/s3api/auth_credentials.go | 234 +++++++++++++++++++++++ weed/s3api/auth_credentials_subscribe.go | 4 - 2 files changed, 234 insertions(+), 4 deletions(-) diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 953e3311e..1e93bb632 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -62,6 +62,10 @@ type IdentityAccessManagement struct { // useStaticConfig indicates if the configuration was loaded from a static file useStaticConfig bool + + // staticIdentityNames tracks identity names loaded from the static config file + // These identities are immutable and cannot be updated by dynamic configuration + staticIdentityNames map[string]bool } type Identity struct { @@ -166,6 +170,15 @@ func NewIdentityAccessManagementWithStore(option *S3ApiServerOption, explicitSto glog.Fatalf("fail to load config file %s: %v", option.Config, err) } iam.useStaticConfig = true + + // Track identity names from static config to protect them from dynamic updates + iam.m.Lock() + iam.staticIdentityNames = make(map[string]bool) + for _, identity := range iam.identities { + iam.staticIdentityNames[identity.Name] = true + } + iam.m.Unlock() + // Check if any identities were actually loaded from the config file iam.m.RLock() configLoaded = len(iam.identities) > 0 @@ -265,6 +278,22 @@ func (iam *IdentityAccessManagement) LoadS3ApiConfigurationFromBytes(content []b } func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3ApiConfiguration) error { + // Check if we need to merge with existing static configuration + iam.m.RLock() + hasStaticConfig := iam.useStaticConfig && len(iam.staticIdentityNames) > 0 + iam.m.RUnlock() + + if hasStaticConfig { + // Merge mode: preserve static identities, add/update dynamic ones + return iam.mergeS3ApiConfiguration(config) + } + + // Normal mode: completely replace configuration + return iam.replaceS3ApiConfiguration(config) +} + +// replaceS3ApiConfiguration completely replaces the current configuration (used when no static config) +func (iam *IdentityAccessManagement) replaceS3ApiConfiguration(config *iam_pb.S3ApiConfiguration) error { var identities []*Identity var identityAnonymous *Identity accessKeyIdent := make(map[string]*Identity) @@ -405,6 +434,204 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api return nil } +// mergeS3ApiConfiguration merges dynamic configuration with existing static configuration +// Static identities (from file) are preserved and cannot be updated +// Dynamic identities (from filer/admin) can be added or updated +func (iam *IdentityAccessManagement) mergeS3ApiConfiguration(config *iam_pb.S3ApiConfiguration) error { + // Start with current configuration (which includes static identities) + iam.m.RLock() + identities := make([]*Identity, len(iam.identities)) + copy(identities, iam.identities) + identityAnonymous := iam.identityAnonymous + accessKeyIdent := make(map[string]*Identity) + for k, v := range iam.accessKeyIdent { + accessKeyIdent[k] = v + } + nameToIdentity := make(map[string]*Identity) + for k, v := range iam.nameToIdentity { + nameToIdentity[k] = v + } + accounts := make(map[string]*Account) + for k, v := range iam.accounts { + accounts[k] = v + } + emailAccount := make(map[string]*Account) + for k, v := range iam.emailAccount { + emailAccount[k] = v + } + staticNames := make(map[string]bool) + for k, v := range iam.staticIdentityNames { + staticNames[k] = v + } + iam.m.RUnlock() + + // Process accounts from dynamic config (can add new accounts) + for _, account := range config.Accounts { + if _, exists := accounts[account.Id]; !exists { + glog.V(3).Infof("adding dynamic account: name=%s, id=%s", account.DisplayName, account.Id) + accounts[account.Id] = &Account{ + Id: account.Id, + DisplayName: account.DisplayName, + EmailAddress: account.EmailAddress, + } + if account.EmailAddress != "" { + emailAccount[account.EmailAddress] = accounts[account.Id] + } + } + } + + // Ensure default accounts exist + if _, exists := accounts[AccountAdmin.Id]; !exists { + accounts[AccountAdmin.Id] = &Account{ + DisplayName: AccountAdmin.DisplayName, + EmailAddress: AccountAdmin.EmailAddress, + Id: AccountAdmin.Id, + } + emailAccount[AccountAdmin.EmailAddress] = accounts[AccountAdmin.Id] + } + if _, exists := accounts[AccountAnonymous.Id]; !exists { + accounts[AccountAnonymous.Id] = &Account{ + DisplayName: AccountAnonymous.DisplayName, + EmailAddress: AccountAnonymous.EmailAddress, + Id: AccountAnonymous.Id, + } + emailAccount[AccountAnonymous.EmailAddress] = accounts[AccountAnonymous.Id] + } + + // Process identities from dynamic config + for _, ident := range config.Identities { + // Skip static identities - they cannot be updated + if staticNames[ident.Name] { + glog.V(3).Infof("skipping static identity %s (immutable)", ident.Name) + continue + } + + glog.V(3).Infof("loading/updating dynamic identity %s (disabled=%v)", ident.Name, ident.Disabled) + t := &Identity{ + Name: ident.Name, + Credentials: nil, + Actions: nil, + PrincipalArn: generatePrincipalArn(ident.Name), + Disabled: ident.Disabled, + PolicyNames: ident.PolicyNames, + } + + switch { + case ident.Name == AccountAnonymous.Id: + t.Account = &AccountAnonymous + identityAnonymous = t + case ident.Account == nil: + t.Account = &AccountAdmin + default: + if account, ok := accounts[ident.Account.Id]; ok { + t.Account = account + } else { + t.Account = &AccountAdmin + glog.Warningf("identity %s is associated with a non exist account ID, the association is invalid", ident.Name) + } + } + + for _, action := range ident.Actions { + t.Actions = append(t.Actions, Action(action)) + } + for _, cred := range ident.Credentials { + t.Credentials = append(t.Credentials, &Credential{ + AccessKey: cred.AccessKey, + SecretKey: cred.SecretKey, + Status: cred.Status, + }) + accessKeyIdent[cred.AccessKey] = t + } + + // Update or add the identity + if existingIdx := -1; existingIdx >= 0 { + // Find and replace existing dynamic identity + for i, existing := range identities { + if existing.Name == ident.Name { + existingIdx = i + break + } + } + if existingIdx >= 0 { + identities[existingIdx] = t + } + } else { + // Add new dynamic identity + identities = append(identities, t) + } + nameToIdentity[t.Name] = t + } + + // Process service accounts from dynamic config + for _, sa := range config.ServiceAccounts { + if sa.Credential == nil { + continue + } + + // Skip disabled service accounts + if sa.Disabled { + glog.V(3).Infof("Skipping disabled service account %s", sa.Id) + continue + } + + // Find the parent identity + parentIdent, ok := nameToIdentity[sa.ParentUser] + if !ok { + glog.Warningf("Service account %s has non-existent parent user %s, skipping", sa.Id, sa.ParentUser) + continue + } + + // Skip if parent is a static identity (we don't modify static identities) + if staticNames[sa.ParentUser] { + glog.V(3).Infof("Skipping service account %s for static parent %s", sa.Id, sa.ParentUser) + continue + } + + // Add service account credential to parent identity + cred := &Credential{ + AccessKey: sa.Credential.AccessKey, + SecretKey: sa.Credential.SecretKey, + Status: sa.Credential.Status, + Expiration: sa.Expiration, + } + parentIdent.Credentials = append(parentIdent.Credentials, cred) + accessKeyIdent[sa.Credential.AccessKey] = parentIdent + glog.V(3).Infof("Loaded service account %s for dynamic parent %s (expiration: %d)", sa.Id, sa.ParentUser, sa.Expiration) + } + + iam.m.Lock() + // atomically switch + iam.identities = identities + iam.identityAnonymous = identityAnonymous + iam.accounts = accounts + iam.emailAccount = emailAccount + iam.accessKeyIdent = accessKeyIdent + iam.nameToIdentity = nameToIdentity + if !iam.isAuthEnabled { + iam.isAuthEnabled = len(identities) > 0 + } + iam.m.Unlock() + + // Log configuration summary + staticCount := len(staticNames) + dynamicCount := len(identities) - staticCount + glog.V(1).Infof("Merged config: %d static + %d dynamic identities = %d total, %d accounts, %d access keys. Auth enabled: %v", + staticCount, dynamicCount, len(identities), len(accounts), len(accessKeyIdent), iam.isAuthEnabled) + + if glog.V(2) { + glog.V(2).Infof("Access key to identity mapping:") + for accessKey, identity := range accessKeyIdent { + identityType := "dynamic" + if staticNames[identity.Name] { + identityType = "static" + } + glog.V(2).Infof(" %s -> %s (%s, actions: %d)", accessKey, identity.Name, identityType, len(identity.Actions)) + } + } + + return nil +} + func (iam *IdentityAccessManagement) isEnabled() bool { return iam.isAuthEnabled } @@ -413,6 +640,13 @@ func (iam *IdentityAccessManagement) IsStaticConfig() bool { return iam.useStaticConfig } +// IsStaticIdentity checks if an identity was loaded from the static config file +func (iam *IdentityAccessManagement) IsStaticIdentity(identityName string) bool { + iam.m.RLock() + defer iam.m.RUnlock() + return iam.staticIdentityNames[identityName] +} + func (iam *IdentityAccessManagement) lookupByAccessKey(accessKey string) (identity *Identity, cred *Credential, found bool) { iam.m.RLock() defer iam.m.RUnlock() diff --git a/weed/s3api/auth_credentials_subscribe.go b/weed/s3api/auth_credentials_subscribe.go index eaafa444e..e2d54e307 100644 --- a/weed/s3api/auth_credentials_subscribe.go +++ b/weed/s3api/auth_credentials_subscribe.go @@ -59,10 +59,6 @@ func (s3a *S3ApiServer) onIamConfigChange(dir string, oldEntry *filer_pb.Entry, if dir != filer.IamConfigDirectory { return nil } - if s3a.iam.IsStaticConfig() { - glog.V(0).Infof("Ignoring IAM config change because static configuration file is in use") - return nil - } // Handle deletion: reset to empty config if newEntry == nil && oldEntry != nil && oldEntry.Name == filer.IamIdentityFile {