diff --git a/weed/admin/view/app/object_store_users.templ b/weed/admin/view/app/object_store_users.templ index aa47ab539b..51e2e58546 100644 --- a/weed/admin/view/app/object_store_users.templ +++ b/weed/admin/view/app/object_store_users.templ @@ -230,6 +230,8 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) { + + @@ -342,6 +344,8 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) { + + @@ -405,6 +409,11 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) { Hold Ctrl/Cmd to select multiple buckets +
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
Grants the permission and bucket pickers above cannot show. Uncheck a grant to remove it.
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/s3api/s3api_object_upload_acl.go b/weed/s3api/s3api_object_upload_acl.go index 4c02ed3c24..b9dd038fe7 100644 --- a/weed/s3api/s3api_object_upload_acl.go +++ b/weed/s3api/s3api_object_upload_acl.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/aws/aws-sdk-go/service/s3" + "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" @@ -156,8 +157,12 @@ func (s3a *S3ApiServer) preparePutObjectACL(r *http.Request, bucket string) (*ht // Setting an ACL during upload or copy also requires s3:PutObjectAcl. Use the // unified authorization path so bucket-policy allows and explicit denies // retain the same semantics as standalone ACL requests. - identity, authCode := s3a.iam.authRequest(r.Clone(r.Context()), s3_constants.ACTION_WRITE_ACP) + identity, authCode, _ := s3a.iam.authRequestWithAuthType(r.Clone(r.Context()), s3_constants.ACTION_WRITE_ACP) if authCode != s3err.ErrNone { + if authCode == s3err.ErrAccessDenied && identity != nil && identity.Name != s3_constants.AccountAnonymousId { + _, object := s3_constants.GetBucketAndObject(r) + glog.Infof("denied %s %q for %q: the request sets an ACL and failed the %s (s3:PutObjectAcl) check", r.Method, bucket+"/"+object, identity.Name, s3_constants.ACTION_WRITE_ACP) + } return r, authCode } if identity == nil || identity.Account == nil { diff --git a/weed/shell/command_s3_anonymous_set.go b/weed/shell/command_s3_anonymous_set.go index 0a2616cd0f..4468584f7e 100644 --- a/weed/shell/command_s3_anonymous_set.go +++ b/weed/shell/command_s3_anonymous_set.go @@ -31,7 +31,7 @@ func (c *commandS3AnonymousSet) Help() string { s3.anonymous.set -bucket -access Read,List s3.anonymous.set -bucket -access none - Supported actions: Read, Write, List, Tagging, Admin + Supported actions: Read, ReadAcp, Write, WriteAcp, List, Tagging, Admin Use "none" to remove all anonymous access for the bucket. This manages the special "anonymous" user's actions. It does not @@ -46,7 +46,7 @@ func (c *commandS3AnonymousSet) HasTag(CommandTag) bool { func (c *commandS3AnonymousSet) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) bucket := f.String("bucket", "", "bucket name") - access := f.String("access", "", "comma-separated actions: Read,Write,List,Tagging,Admin or none") + access := f.String("access", "", "comma-separated actions: Read,ReadAcp,Write,WriteAcp,List,Tagging,Admin or none") if err := f.Parse(args); err != nil { return err } @@ -57,6 +57,16 @@ func (c *commandS3AnonymousSet) Do(args []string, commandEnv *CommandEnv, writer if *access == "" { return fmt.Errorf("-access is required") } + var granted []string + if strings.ToLower(strings.TrimSpace(*access)) != "none" { + var err error + if granted, err = canonicalActionList(*access); err != nil { + return err + } + if len(granted) == 0 { + return fmt.Errorf("invalid -access %q: use %s, or none", *access, supportedActionNames) + } + } return commandEnv.withIamClient(func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error { // Get or create anonymous user @@ -74,27 +84,8 @@ func (c *commandS3AnonymousSet) Do(args []string, commandEnv *CommandEnv, writer } } - // Add new actions unless "none" - canonicalActions := map[string]string{ - "read": "Read", "write": "Write", "list": "List", - "tagging": "Tagging", "admin": "Admin", - } - if strings.ToLower(strings.TrimSpace(*access)) != "none" { - seen := make(map[string]struct{}) - for _, action := range strings.Split(*access, ",") { - action = strings.TrimSpace(action) - if action != "" { - canonical, ok := canonicalActions[strings.ToLower(action)] - if !ok { - return fmt.Errorf("invalid action %q: supported actions are Read, Write, List, Tagging, Admin", action) - } - if _, dup := seen[canonical]; dup { - continue - } - seen[canonical] = struct{}{} - kept = append(kept, canonical+":"+*bucket) - } - } + for _, action := range granted { + kept = append(kept, action+":"+*bucket) } identity.Actions = kept diff --git a/weed/shell/command_s3_bucket_access.go b/weed/shell/command_s3_bucket_access.go index f4f216e289..66e4b33e03 100644 --- a/weed/shell/command_s3_bucket_access.go +++ b/weed/shell/command_s3_bucket_access.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "io" + "slices" "sort" "strings" @@ -17,11 +18,35 @@ import ( // canonicalActions maps lowercased action names to their canonical form. var canonicalActions = map[string]string{ - "read": "Read", - "write": "Write", - "list": "List", - "tagging": "Tagging", - "admin": "Admin", + "read": "Read", + "readacp": "ReadAcp", + "write": "Write", + "writeacp": "WriteAcp", + "list": "List", + "tagging": "Tagging", + "admin": "Admin", +} + +const supportedActionNames = "Read, ReadAcp, Write, WriteAcp, List, Tagging, Admin" + +// canonicalActionList parses a comma-separated action list into canonical +// names, skipping empty entries and repeats. +func canonicalActionList(list string) ([]string, error) { + var actions []string + for _, a := range strings.Split(list, ",") { + a = strings.TrimSpace(a) + if a == "" { + continue + } + canonical, ok := canonicalActions[strings.ToLower(a)] + if !ok { + return nil, fmt.Errorf("invalid action %q: supported actions are %s", a, supportedActionNames) + } + if !slices.Contains(actions, canonical) { + actions = append(actions, canonical) + } + } + return actions, nil } func init() { @@ -45,14 +70,16 @@ func (c *commandS3BucketAccess) Help() string { # Grant anonymous read and list access s3.bucket.access -name -user anonymous -access Read,List - # Grant full anonymous access + # Grant anonymous read, write and list access s3.bucket.access -name -user anonymous -access Read,Write,List # Remove all access for a user on a bucket s3.bucket.access -name -user -access none Supported action names (comma-separated): - Read, Write, List, Tagging, Admin + Read, ReadAcp, Write, WriteAcp, List, Tagging, Admin + ReadAcp covers GET ?acl and WriteAcp covers PUT ?acl. An upload + that carries an ACL header such as x-amz-acl needs Write and WriteAcp. The user is auto-created if it does not exist. Actions are scoped to the specified bucket (stored as "Action:bucket" in the identity). @@ -68,7 +95,7 @@ func (c *commandS3BucketAccess) Do(args []string, commandEnv *CommandEnv, writer bucketCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError) bucketName := bucketCommand.String("name", "", "bucket name") userName := bucketCommand.String("user", "", "user name") - access := bucketCommand.String("access", "", "comma-separated actions: Read,Write,List,Tagging,Admin or none") + access := bucketCommand.String("access", "", "comma-separated actions: Read,ReadAcp,Write,WriteAcp,List,Tagging,Admin or none") if err = bucketCommand.Parse(args); err != nil { return err } @@ -82,16 +109,13 @@ func (c *commandS3BucketAccess) Do(args []string, commandEnv *CommandEnv, writer accessStr := strings.TrimSpace(*access) - // Validate and normalize actions to canonical casing if accessStr != "" && strings.ToLower(accessStr) != "none" { - var normalized []string - for _, a := range strings.Split(accessStr, ",") { - a = strings.TrimSpace(a) - canonical, ok := canonicalActions[strings.ToLower(a)] - if !ok { - return fmt.Errorf("invalid action %q: must be Read, Write, List, Tagging, Admin, or none", a) - } - normalized = append(normalized, canonical) + normalized, err := canonicalActionList(accessStr) + if err != nil { + return err + } + if len(normalized) == 0 { + return fmt.Errorf("invalid -access %q: use %s, or none", accessStr, supportedActionNames) } accessStr = strings.Join(normalized, ",") } diff --git a/weed/shell/command_s3_bucket_access_test.go b/weed/shell/command_s3_bucket_access_test.go index 2bcd4d3336..9c971da6ef 100644 --- a/weed/shell/command_s3_bucket_access_test.go +++ b/weed/shell/command_s3_bucket_access_test.go @@ -4,11 +4,54 @@ import ( "bytes" "slices" "sort" + "strings" "testing" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" ) +func TestCanonicalActionsAcceptAcpActions(t *testing.T) { + names := strings.Split(supportedActionNames, ", ") + for _, action := range []string{ + s3_constants.ACTION_READ, s3_constants.ACTION_READ_ACP, + s3_constants.ACTION_WRITE, s3_constants.ACTION_WRITE_ACP, + s3_constants.ACTION_LIST, s3_constants.ACTION_TAGGING, s3_constants.ACTION_ADMIN, + } { + for _, input := range []string{action, strings.ToLower(action), strings.ToUpper(action)} { + if got := canonicalActions[strings.ToLower(input)]; got != action { + t.Errorf("canonicalActions[%q] = %q, want %q", input, got, action) + } + } + if !slices.Contains(names, action) { + t.Errorf("supportedActionNames %q does not list %s", supportedActionNames, action) + } + } + if len(names) != len(canonicalActions) { + t.Errorf("supportedActionNames lists %d actions, canonicalActions has %d", len(names), len(canonicalActions)) + } +} + +func TestCanonicalActionList(t *testing.T) { + got, err := canonicalActionList(" readacp, WriteAcp,,writeacp ,list") + if err != nil { + t.Fatalf("canonicalActionList: %v", err) + } + if want := []string{"ReadAcp", "WriteAcp", "List"}; !slices.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } + if _, err := canonicalActionList("Read,PutObjectAcl"); err == nil { + t.Error("expected an error for an unsupported action") + } +} + +func TestAnonymousSetRejectsEmptyAccess(t *testing.T) { + err := (&commandS3AnonymousSet{}).Do([]string{"-bucket", "b", "-access", " , ,"}, nil, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "invalid -access") { + t.Errorf("got %v, want an invalid -access error", err) + } +} + func TestUpdateBucketActions_SetActions(t *testing.T) { identity := &iam_pb.Identity{ Name: "anonymous", @@ -39,6 +82,24 @@ func TestUpdateBucketActions_ReplaceActions(t *testing.T) { } } +func TestUpdateBucketActions_AcpActions(t *testing.T) { + identity := &iam_pb.Identity{ + Name: "testuser", + Actions: []string{"Read:my-bucket", "Write:other-bucket"}, + } + + normalized, err := canonicalActionList("read,readacp,WRITEACP") + if err != nil { + t.Fatalf("canonicalActionList: %v", err) + } + updateBucketActions(identity, "my-bucket", strings.Join(normalized, ",")) + + expected := []string{"Write:other-bucket", "Read:my-bucket", "ReadAcp:my-bucket", "WriteAcp:my-bucket"} + if !slices.Equal(identity.Actions, expected) { + t.Errorf("got %v, want %v", identity.Actions, expected) + } +} + func TestUpdateBucketActions_None(t *testing.T) { identity := &iam_pb.Identity{ Name: "anonymous", diff --git a/weed/shell/command_s3_circuitbreaker.go b/weed/shell/command_s3_circuitbreaker.go index 772cacc789..e4d7cbef14 100644 --- a/weed/shell/command_s3_circuitbreaker.go +++ b/weed/shell/command_s3_circuitbreaker.go @@ -65,7 +65,7 @@ func (c *commandS3CircuitBreaker) Do(args []string, commandEnv *CommandEnv, writ buckets := s3CircuitBreakerCommand.String("buckets", "", "the bucket name(s) to configure, eg: -buckets x,y,z") global := s3CircuitBreakerCommand.Bool("global", false, "configure global circuit breaker") - actions := s3CircuitBreakerCommand.String("actions", "", "comma separated actions names: Read,Write,List,Tagging,Admin") + actions := s3CircuitBreakerCommand.String("actions", "", "comma separated actions names: "+strings.Join(s3_constants.AllowedActions, ",")) limitType := s3CircuitBreakerCommand.String("type", "", "'Count' or 'MB'; Count represents the number of simultaneous requests, and MB represents the content size of all simultaneous requests") values := s3CircuitBreakerCommand.String("values", "", "comma separated values") diff --git a/weed/shell/command_s3_configure.go b/weed/shell/command_s3_configure.go index f321e1ba71..eb6b8a5cc2 100644 --- a/weed/shell/command_s3_configure.go +++ b/weed/shell/command_s3_configure.go @@ -34,6 +34,13 @@ func (c *commandS3Configure) Help() string { # create a new identity with account information s3.configure -user=username -actions=Read,Write,List,Tagging -buckets=bucket-name -policies=policy1,policy2 -access_key=key -secret_key=secret -account_id=id -account_display_name=name -account_email=email@example.com -apply + + # allow uploads that send an ACL header such as x-amz-acl + s3.configure -user=username -actions=Write,WriteAcp -buckets=bucket-name -apply + + Common action names: Read, ReadAcp, Write, WriteAcp, List, Tagging, Admin + ReadAcp covers GET ?acl and WriteAcp covers PUT ?acl. An upload or copy + that carries an ACL header such as x-amz-acl needs both Write and WriteAcp. ` } @@ -44,7 +51,7 @@ func (c *commandS3Configure) HasTag(tag CommandTag) bool { func (c *commandS3Configure) Do(args []string, commandEnv *CommandEnv, writer io.Writer) (err error) { s3ConfigureCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError) - actions := s3ConfigureCommand.String("actions", "", "comma separated actions names: Read,Write,List,Tagging,Admin") + actions := s3ConfigureCommand.String("actions", "", "comma separated actions names: Read,ReadAcp,Write,WriteAcp,List,Tagging,Admin") user := s3ConfigureCommand.String("user", "", "user name") buckets := s3ConfigureCommand.String("buckets", "", "bucket name") accessKey := s3ConfigureCommand.String("access_key", "", "specify the access key") diff --git a/weed/shell/command_s3_serviceaccount_create.go b/weed/shell/command_s3_serviceaccount_create.go index ca6b076c76..fc3c5b972d 100644 --- a/weed/shell/command_s3_serviceaccount_create.go +++ b/weed/shell/command_s3_serviceaccount_create.go @@ -7,7 +7,6 @@ import ( "flag" "fmt" "io" - "strings" "time" "github.com/seaweedfs/seaweedfs/weed/iam" @@ -84,26 +83,12 @@ func (c *commandS3ServiceAccountCreate) Do(args []string, commandEnv *CommandEnv CreatedAt: time.Now().Unix(), } - validActions := map[string]string{ - "read": "Read", "write": "Write", "list": "List", - "tagging": "Tagging", "admin": "Admin", - } if *actions != "" { - seen := make(map[string]struct{}) - for _, a := range strings.Split(*actions, ",") { - a = strings.TrimSpace(a) - if a != "" { - canonical, ok := validActions[strings.ToLower(a)] - if !ok { - return fmt.Errorf("invalid action %q: supported actions are Read, Write, List, Tagging, Admin", a) - } - if _, dup := seen[canonical]; dup { - continue - } - seen[canonical] = struct{}{} - sa.Actions = append(sa.Actions, canonical) - } + parsed, err := canonicalActionList(*actions) + if err != nil { + return err } + sa.Actions = parsed } if *expiry < 0 {