diff --git a/docker/compose/s3_static_users_example.json b/docker/compose/s3_static_users_example.json new file mode 100644 index 000000000..d8d87340b --- /dev/null +++ b/docker/compose/s3_static_users_example.json @@ -0,0 +1,53 @@ +{ + "identities": [ + { + "name": "admin", + "credentials": [ + { + "accessKey": "AKIAIOSFODNN7EXAMPLE", + "secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + ], + "actions": [ + "Admin", + "Read", + "List", + "Tagging", + "Write" + ] + }, + { + "name": "steward", + "credentials": [ + { + "accessKey": "steward-key", + "secretKey": "steward-secret" + } + ], + "actions": [ + "Read", + "List", + "Write" + ] + }, + { + "name": "le001", + "credentials": [ + { + "accessKey": "le001-key", + "secretKey": "le001-secret" + } + ], + "actions": [ + "Read", + "List" + ] + }, + { + "name": "anonymous", + "actions": [ + "Read" + ] + } + ] +} diff --git a/weed/admin/dash/admin_data.go b/weed/admin/dash/admin_data.go index f91fc7084..bddaae65d 100644 --- a/weed/admin/dash/admin_data.go +++ b/weed/admin/dash/admin_data.go @@ -45,6 +45,7 @@ type ObjectStoreUser struct { SecretKey string `json:"secret_key"` Permissions []string `json:"permissions"` PolicyNames []string `json:"policy_names"` + IsStatic bool `json:"is_static"` // loaded from static config file, not editable } type ObjectStoreUsersData struct { diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index ed59b22f4..a339e4d39 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -867,6 +867,24 @@ func (s *AdminServer) DeleteS3Bucket(bucketName string) error { }) } +// IsStaticUser checks if a user is a static identity by loading the +// configuration from the credential manager and checking the IsStatic flag. +func (s *AdminServer) IsStaticUser(username string) bool { + if s.credentialManager == nil { + return false + } + s3cfg, err := s.credentialManager.LoadConfiguration(context.Background()) + if err != nil { + return false + } + for _, ident := range s3cfg.Identities { + if ident.Name == username { + return ident.IsStatic + } + } + return false +} + // GetObjectStoreUsers retrieves object store users from identity.json func (s *AdminServer) GetObjectStoreUsers(ctx context.Context) ([]ObjectStoreUser, error) { if s.credentialManager == nil { @@ -890,6 +908,7 @@ func (s *AdminServer) GetObjectStoreUsers(ctx context.Context) ([]ObjectStoreUse user := ObjectStoreUser{ Username: identity.Name, Permissions: identity.Actions, + IsStatic: identity.IsStatic, } // Set email from account if available diff --git a/weed/admin/dash/user_management.go b/weed/admin/dash/user_management.go index 7335d70cb..009f5cfca 100644 --- a/weed/admin/dash/user_management.go +++ b/weed/admin/dash/user_management.go @@ -175,7 +175,7 @@ func (s *AdminServer) GetObjectStoreUserDetails(username string) (*UserDetails, ctx := context.Background() - // Get user using credential manager + // Get user using credential manager (resolves static users via filer gRPC) identity, err := s.credentialManager.GetUser(ctx, username) if err != nil { if err == credential.ErrUserNotFound { diff --git a/weed/admin/handlers/user_handlers.go b/weed/admin/handlers/user_handlers.go index dbace9f2c..e439b3ad8 100644 --- a/weed/admin/handlers/user_handlers.go +++ b/weed/admin/handlers/user_handlers.go @@ -93,6 +93,11 @@ func (h *UserHandlers) UpdateUser(w http.ResponseWriter, r *http.Request) { return } + if h.adminServer.IsStaticUser(username) { + writeJSONError(w, http.StatusForbidden, "Cannot modify static user "+username+" (loaded from config file)") + return + } + var req dash.UpdateUserRequest if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) @@ -120,6 +125,11 @@ func (h *UserHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) { return } + if h.adminServer.IsStaticUser(username) { + writeJSONError(w, http.StatusForbidden, "Cannot delete static user "+username+" (loaded from config file)") + return + } + err := h.adminServer.DeleteObjectStoreUser(username) if err != nil { glog.Errorf("Failed to delete user %s: %v", username, err) diff --git a/weed/admin/view/app/object_store_users.templ b/weed/admin/view/app/object_store_users.templ index 0bc6f2ab3..cc16a3e53 100644 --- a/weed/admin/view/app/object_store_users.templ +++ b/weed/admin/view/app/object_store_users.templ @@ -125,6 +125,9 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
{user.Username} + if user.IsStatic { + static + }
{user.Email} @@ -133,24 +136,28 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
- - - if user.Username != "anonymous" { + if !user.IsStatic { + + } + if user.Username != "anonymous" && !user.IsStatic { } - + if !user.IsStatic { + + }
diff --git a/weed/admin/view/app/object_store_users_templ.go b/weed/admin/view/app/object_store_users_templ.go index 91a212b6e..2917d2f3f 100644 --- a/weed/admin/view/app/object_store_users_templ.go +++ b/weed/admin/view/app/object_store_users_templ.go @@ -41,7 +41,7 @@ func ObjectStoreUsers(data dash.ObjectStoreUsersData) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalUsers)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 38, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/object_store_users.templ`, Line: 38, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -54,7 +54,7 @@ func ObjectStoreUsers(data dash.ObjectStoreUsersData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.Users))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 58, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/object_store_users.templ`, Line: 58, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -67,7 +67,7 @@ func ObjectStoreUsers(data dash.ObjectStoreUsersData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("15:04")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 78, Col: 69} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/object_store_users.templ`, Line: 78, Col: 69} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -85,140 +85,162 @@ func ObjectStoreUsers(data dash.ObjectStoreUsersData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 127, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/object_store_users.templ`, Line: 127, Col: 74} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if user.IsStatic { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "static") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 130, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/object_store_users.templ`, Line: 133, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(user.AccessKey) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 132, Col: 88} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/object_store_users.templ`, Line: 135, Col: 88} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 141, Col: 113} + if !user.IsStatic { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"> ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if user.Username != "anonymous" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 151, Col: 115} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(data.Users) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
No users found

Create your first object store user to get started.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
No users found

Create your first object store user to get started.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
Last updated: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/object_store_users.templ`, Line: 182, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/object_store_users.templ`, Line: 189, Col: 81} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
Create New User
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
Create New User
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !data.HasAnonymousUser { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Hold Ctrl/Cmd to select multiple permissions
Apply selected permissions to specific buckets or all buckets
Hold Ctrl/Cmd to select multiple buckets
Hold Ctrl/Cmd to select multiple policies
Edit User
Apply selected permissions to specific buckets or all buckets
Hold Ctrl/Cmd to select multiple buckets
User Details
Manage Access Keys
Access Keys for

Leave blank to auto-generate.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
Hold Ctrl/Cmd to select multiple permissions
Apply selected permissions to specific buckets or all buckets
Hold Ctrl/Cmd to select multiple buckets
Hold Ctrl/Cmd to select multiple policies
Edit User
Apply selected permissions to specific buckets or all buckets
Hold Ctrl/Cmd to select multiple buckets
User Details
Manage Access Keys
Access Keys for

Leave blank to auto-generate.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/command/filer.go b/weed/command/filer.go index edfd8afef..75d3ccfd4 100644 --- a/weed/command/filer.go +++ b/weed/command/filer.go @@ -81,6 +81,7 @@ type FilerOptions struct { exposeDirectoryData *bool tusBasePath *string certProvider certprovider.Provider + s3ConfigFile *string // optional path to static S3 identity config } func init() { @@ -343,6 +344,15 @@ func (fo *FilerOptions) startFiler() { glog.V(0).Infof("Initialized credential manager: %s", credentialManager.GetStoreName()) } + // Load static S3 identities from config file if specified + if fo.s3ConfigFile != nil && *fo.s3ConfigFile != "" { + if credentialManager != nil { + if err := credentialManager.LoadS3ConfigFile(*fo.s3ConfigFile); err != nil { + glog.Warningf("Failed to load S3 config file for static identities: %v", err) + } + } + } + fs, nfs_err := weed_server.NewFilerServer(defaultMux, publicVolumeMux, &weed_server.FilerOption{ Masters: fo.masters, FilerGroup: *fo.filerGroup, diff --git a/weed/command/mini.go b/weed/command/mini.go index 38257bdff..f23797b5e 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -820,6 +820,10 @@ func runMini(cmd *Command, args []string) bool { miniFilerOptions.disableHttp = miniDisableHttp miniMasterOptions.disableHttp = miniDisableHttp + // Share the S3 static identity config file with the filer so its + // credential manager can also serve static users. + miniFilerOptions.s3ConfigFile = miniS3Config + filerAddress := string(pb.NewServerAddress(*miniIp, *miniFilerOptions.port, *miniFilerOptions.portGrpc)) miniS3Options.filer = &filerAddress miniWebDavOptions.filer = &filerAddress diff --git a/weed/command/server.go b/weed/command/server.go index deb1c5e71..9533c967e 100644 --- a/weed/command/server.go +++ b/weed/command/server.go @@ -372,6 +372,8 @@ func runServer(cmd *Command, args []string) bool { } else if *serverIamConfig != "" && *s3Options.iamConfig != *serverIamConfig { glog.V(0).Infof("both -s3.iam.config(%s) and -iam.config(%s) provided; using -s3.iam.config", *s3Options.iamConfig, *serverIamConfig) } + // Share the S3 static identity config file with the filer + filerOptions.s3ConfigFile = s3Options.config go func() { time.Sleep(2 * time.Second) s3Options.localFilerSocket = filerOptions.localSocket diff --git a/weed/credential/credential_manager.go b/weed/credential/credential_manager.go index d9df17e50..a53015c2a 100644 --- a/weed/credential/credential_manager.go +++ b/weed/credential/credential_manager.go @@ -3,14 +3,18 @@ package credential import ( "context" "fmt" + "os" "strings" + "sync" + "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" "github.com/seaweedfs/seaweedfs/weed/util" "github.com/seaweedfs/seaweedfs/weed/wdclient" "google.golang.org/grpc" + "google.golang.org/protobuf/encoding/protojson" ) // FilerAddressSetter is an interface for credential stores that need a dynamic filer address @@ -21,6 +25,15 @@ type FilerAddressSetter interface { // CredentialManager manages user credentials using a configurable store type CredentialManager struct { Store CredentialStore + // staticMu protects staticIdentities and staticNames, which are written + // by SetStaticIdentities (startup + config reload) and read concurrently + // by LoadConfiguration, SaveConfiguration, GetStaticUsernames, and IsStaticIdentity. + staticMu sync.RWMutex + // staticIdentities holds identities loaded from a static config file (-s3.config). + // These are included in LoadConfiguration so that listing operations + // return all configured identities, not just dynamic ones from the store. + staticIdentities []*iam_pb.Identity + staticNames map[string]bool } // NewCredentialManager creates a new credential manager with the specified store @@ -74,13 +87,97 @@ func (cm *CredentialManager) GetStoreName() string { return "" } -// LoadConfiguration loads the S3 API configuration -func (cm *CredentialManager) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) { - return cm.Store.LoadConfiguration(ctx) +// SetStaticIdentities registers identities loaded from a static config file. +// These identities are included in LoadConfiguration and ListUsers results +// but are never persisted to the dynamic store. +func (cm *CredentialManager) SetStaticIdentities(identities []*iam_pb.Identity) { + filtered := make([]*iam_pb.Identity, 0, len(identities)) + names := make(map[string]bool, len(identities)) + for _, ident := range identities { + if ident != nil { + filtered = append(filtered, ident) + names[ident.Name] = true + } + } + cm.staticMu.Lock() + cm.staticIdentities = filtered + cm.staticNames = names + cm.staticMu.Unlock() } -// SaveConfiguration saves the S3 API configuration +// IsStaticIdentity returns true if the named identity was loaded from static config. +func (cm *CredentialManager) IsStaticIdentity(name string) bool { + cm.staticMu.RLock() + defer cm.staticMu.RUnlock() + return cm.staticNames[name] +} + +// GetStaticIdentity returns the protobuf identity for a static user, or nil. +func (cm *CredentialManager) GetStaticIdentity(name string) *iam_pb.Identity { + cm.staticMu.RLock() + defer cm.staticMu.RUnlock() + for _, ident := range cm.staticIdentities { + if ident.Name == name { + return ident + } + } + return nil +} + +// GetStaticUsernames returns the names of all static identities. +func (cm *CredentialManager) GetStaticUsernames() []string { + cm.staticMu.RLock() + defer cm.staticMu.RUnlock() + names := make([]string, 0, len(cm.staticIdentities)) + for _, ident := range cm.staticIdentities { + names = append(names, ident.Name) + } + return names +} + +// LoadConfiguration loads the S3 API configuration from the store and merges +// in any static identities so that listing operations show all users. +func (cm *CredentialManager) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) { + config, err := cm.Store.LoadConfiguration(ctx) + if err != nil { + return config, err + } + // Merge static identities that are not already in the dynamic config + cm.staticMu.RLock() + staticIdents := cm.staticIdentities + cm.staticMu.RUnlock() + if len(staticIdents) > 0 { + dynamicNames := make(map[string]bool, len(config.Identities)) + for _, ident := range config.Identities { + dynamicNames[ident.Name] = true + } + for _, si := range staticIdents { + if !dynamicNames[si.Name] { + config.Identities = append(config.Identities, si) + } + } + } + return config, nil +} + +// SaveConfiguration saves the S3 API configuration. +// Static identities are filtered out before saving to the store. +// The caller's config is not mutated. func (cm *CredentialManager) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error { + cm.staticMu.RLock() + staticNames := cm.staticNames + cm.staticMu.RUnlock() + if len(staticNames) > 0 { + var dynamicOnly []*iam_pb.Identity + for _, ident := range config.Identities { + if !staticNames[ident.Name] { + dynamicOnly = append(dynamicOnly, ident) + } + } + configCopy := *config + configCopy.Identities = dynamicOnly + return cm.Store.SaveConfiguration(ctx, &configCopy) + } return cm.Store.SaveConfiguration(ctx, config) } @@ -104,7 +201,12 @@ func (cm *CredentialManager) DeleteUser(ctx context.Context, username string) er return cm.Store.DeleteUser(ctx, username) } -// ListUsers returns all usernames +// ListUsers returns usernames from the dynamic store via cm.Store.ListUsers. +// On store error the error is returned directly without merging static entries. +// Static identities (cm.staticIdentities) are NOT included here because +// internal callers (e.g. DeletePolicy) look up each user in the store and +// would fail on non-existent static entries. External callers that need the +// full list should merge GetStaticUsernames separately. func (cm *CredentialManager) ListUsers(ctx context.Context) ([]string, error) { return cm.Store.ListUsers(ctx) } @@ -169,6 +271,26 @@ func (cm *CredentialManager) UpdatePolicy(ctx context.Context, name string, docu return cm.Store.PutPolicy(ctx, name, document) } +// LoadS3ConfigFile reads a static S3 identity config file and registers +// the identities so they appear in LoadConfiguration and listing results. +func (cm *CredentialManager) LoadS3ConfigFile(path string) error { + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + config := &iam_pb.S3ApiConfiguration{} + opts := protojson.UnmarshalOptions{DiscardUnknown: true, AllowPartial: true} + if err := opts.Unmarshal(content, config); err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + for _, ident := range config.Identities { + ident.IsStatic = true + } + cm.SetStaticIdentities(config.Identities) + glog.V(1).Infof("Loaded %d static identities from %s", len(config.Identities), path) + return nil +} + // Shutdown performs cleanup func (cm *CredentialManager) Shutdown() { if cm.Store != nil { diff --git a/weed/pb/iam.proto b/weed/pb/iam.proto index 05b575ce8..a37b8d8c7 100644 --- a/weed/pb/iam.proto +++ b/weed/pb/iam.proto @@ -186,6 +186,7 @@ message Identity { bool disabled = 5; // User status: false = enabled (default), true = disabled repeated string service_account_ids = 6; // IDs of service accounts owned by this user repeated string policy_names = 7; + bool is_static = 8; // Loaded from static config file (read-only, not editable via API) } message Credential { diff --git a/weed/pb/iam_pb/iam.pb.go b/weed/pb/iam_pb/iam.pb.go index 9cbf29ea8..252742b89 100644 --- a/weed/pb/iam_pb/iam.pb.go +++ b/weed/pb/iam_pb/iam.pb.go @@ -1399,6 +1399,7 @@ type Identity struct { Disabled bool `protobuf:"varint,5,opt,name=disabled,proto3" json:"disabled,omitempty"` // User status: false = enabled (default), true = disabled ServiceAccountIds []string `protobuf:"bytes,6,rep,name=service_account_ids,json=serviceAccountIds,proto3" json:"service_account_ids,omitempty"` // IDs of service accounts owned by this user PolicyNames []string `protobuf:"bytes,7,rep,name=policy_names,json=policyNames,proto3" json:"policy_names,omitempty"` + IsStatic bool `protobuf:"varint,8,opt,name=is_static,json=isStatic,proto3" json:"is_static,omitempty"` // Loaded from static config file (read-only, not editable via API) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1482,6 +1483,13 @@ func (x *Identity) GetPolicyNames() []string { return nil } +func (x *Identity) GetIsStatic() bool { + if x != nil { + return x.IsStatic + } + return false +} + type Credential struct { state protoimpl.MessageState `protogen:"open.v1"` AccessKey string `protobuf:"bytes,1,opt,name=access_key,json=accessKey,proto3" json:"access_key,omitempty"` @@ -3013,7 +3021,7 @@ const file_iam_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\amembers\x18\x02 \x03(\tR\amembers\x12!\n" + "\fpolicy_names\x18\x03 \x03(\tR\vpolicyNames\x12\x1a\n" + - "\bdisabled\x18\x04 \x01(\bR\bdisabled\"\x88\x02\n" + + "\bdisabled\x18\x04 \x01(\bR\bdisabled\"\xa5\x02\n" + "\bIdentity\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x124\n" + "\vcredentials\x18\x02 \x03(\v2\x12.iam_pb.CredentialR\vcredentials\x12\x18\n" + @@ -3021,7 +3029,8 @@ const file_iam_proto_rawDesc = "" + "\aaccount\x18\x04 \x01(\v2\x0f.iam_pb.AccountR\aaccount\x12\x1a\n" + "\bdisabled\x18\x05 \x01(\bR\bdisabled\x12.\n" + "\x13service_account_ids\x18\x06 \x03(\tR\x11serviceAccountIds\x12!\n" + - "\fpolicy_names\x18\a \x03(\tR\vpolicyNames\"b\n" + + "\fpolicy_names\x18\a \x03(\tR\vpolicyNames\x12\x1b\n" + + "\tis_static\x18\b \x01(\bR\bisStatic\"b\n" + "\n" + "Credential\x12\x1d\n" + "\n" + diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index ec950cbab..cc0670c02 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -295,6 +295,10 @@ func NewIdentityAccessManagementWithStore(option *S3ApiServerOption, filerClient // This serves as an in-memory "static" configuration iam.loadEnvironmentVariableCredentials() + // Update credential manager with all static identities (file + env vars) + // so that listing operations via filer gRPC also include them. + iam.updateCredentialManagerStaticIdentities() + // Determine whether to enable S3 authentication based on configuration // For "weed mini" without any S3 config, default to allowing all access (isAuthEnabled = false) // If any credentials are configured (via file, filer, or env vars), enable authentication @@ -1069,6 +1073,59 @@ func (iam *IdentityAccessManagement) IsStaticIdentity(identityName string) bool return iam.staticIdentityNames[identityName] } +// updateCredentialManagerStaticIdentities syncs the current set of static +// identities to the credential manager. Call this after any operation that +// changes static identities (startup, config file reload, etc.). +func (iam *IdentityAccessManagement) updateCredentialManagerStaticIdentities() { + if iam.credentialManager != nil { + iam.credentialManager.SetStaticIdentities(iam.GetStaticIdentities()) + } +} + +// GetStaticIdentities returns protobuf representations of all static identities. +// This is used to include static identities in listing operations (ListUsers, etc.) +func (iam *IdentityAccessManagement) GetStaticIdentities() []*iam_pb.Identity { + iam.m.RLock() + defer iam.m.RUnlock() + + var result []*iam_pb.Identity + for _, ident := range iam.identities { + if !ident.IsStatic { + continue + } + var policyNames []string + if len(ident.PolicyNames) > 0 { + policyNames = make([]string, len(ident.PolicyNames)) + copy(policyNames, ident.PolicyNames) + } + pbIdent := &iam_pb.Identity{ + Name: ident.Name, + Disabled: ident.Disabled, + PolicyNames: policyNames, + IsStatic: true, + } + for _, action := range ident.Actions { + pbIdent.Actions = append(pbIdent.Actions, string(action)) + } + for _, cred := range ident.Credentials { + pbIdent.Credentials = append(pbIdent.Credentials, &iam_pb.Credential{ + AccessKey: cred.AccessKey, + SecretKey: cred.SecretKey, + Status: cred.Status, + }) + } + if ident.Account != nil { + pbIdent.Account = &iam_pb.Account{ + Id: ident.Account.Id, + DisplayName: ident.Account.DisplayName, + EmailAddress: ident.Account.EmailAddress, + } + } + result = append(result, pbIdent) + } + return result +} + func (iam *IdentityAccessManagement) lookupByAccessKey(accessKey string) (identity *Identity, cred *Credential, found bool) { iam.m.RLock() defer iam.m.RUnlock() diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 8a817e8ab..4a367a1fb 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -215,6 +215,7 @@ func (e *EmbeddedIamApi) writeIamErrorResponse(w http.ResponseWriter, r *http.Re } // GetS3ApiConfiguration loads the S3 API configuration from the credential manager. +// The credential manager automatically includes static identities in the result. func (e *EmbeddedIamApi) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error { if e.getS3ApiConfigurationFunc != nil { return e.getS3ApiConfigurationFunc(s3cfg) @@ -228,6 +229,7 @@ func (e *EmbeddedIamApi) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) } // PutS3ApiConfiguration saves the S3 API configuration to the credential manager. +// The credential manager automatically filters out static identities before saving. func (e *EmbeddedIamApi) PutS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error { if e.putS3ApiConfigurationFunc != nil { return e.putS3ApiConfigurationFunc(s3cfg) diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index d6a1c1437..1596b247f 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -276,6 +276,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl glog.Errorf("fail to load config file %s: %v", option.Config, err) } else { glog.V(1).Infof("Loaded %d identities from config file %s", len(s3ApiServer.iam.identities), option.Config) + s3ApiServer.iam.updateCredentialManagerStaticIdentities() } }) } diff --git a/weed/server/filer_server.go b/weed/server/filer_server.go index 809c2737e..91564bb22 100644 --- a/weed/server/filer_server.go +++ b/weed/server/filer_server.go @@ -82,6 +82,7 @@ type FilerOption struct { AllowedOrigins []string ExposeDirectoryData bool TusBasePath string + S3ConfigFile string // optional path to static S3 identity config file CredentialManager *credential.CredentialManager } diff --git a/weed/server/filer_server_handlers_iam_grpc.go b/weed/server/filer_server_handlers_iam_grpc.go index c77cf8914..2a97cfd68 100644 --- a/weed/server/filer_server_handlers_iam_grpc.go +++ b/weed/server/filer_server_handlers_iam_grpc.go @@ -101,6 +101,10 @@ func (s *IamGrpcServer) GetUser(ctx context.Context, req *iam_pb.GetUserRequest) identity, err := s.credentialManager.GetUser(ctx, req.Username) if err != nil { if err == credential.ErrUserNotFound { + // Fall back to static identities (loaded from -s3.config file) + if si := s.credentialManager.GetStaticIdentity(req.Username); si != nil { + return &iam_pb.GetUserResponse{Identity: si}, nil + } return nil, status.Errorf(codes.NotFound, "user %s not found", req.Username) } glog.Errorf("Failed to get user %s: %v", req.Username, err) @@ -166,6 +170,20 @@ func (s *IamGrpcServer) ListUsers(ctx context.Context, req *iam_pb.ListUsersRequ return nil, err } + // Merge static identities (from -s3.config file) into the result + staticNames := s.credentialManager.GetStaticUsernames() + if len(staticNames) > 0 { + dynamicSet := make(map[string]bool, len(usernames)) + for _, name := range usernames { + dynamicSet[name] = true + } + for _, name := range staticNames { + if !dynamicSet[name] { + usernames = append(usernames, name) + } + } + } + return &iam_pb.ListUsersResponse{ Usernames: usernames, }, nil