From ccc662b90b91acb198234eb182873730f79afe87 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 25 Mar 2026 23:09:53 -0700 Subject: [PATCH] shell: add s3.bucket.access command for anonymous access policy (#8774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * shell: add s3.bucket.access command for anonymous access policy (#7738) Add a new weed shell command to view or change the anonymous access policy of an S3 bucket without external tools. Usage: s3.bucket.access -name -access read,list s3.bucket.access -name -access none Supported permissions: read, write, list. The command writes a standard bucket policy with Principal "*" and warns if no anonymous IAM identity exists. * shell: fix anonymous identity hint in s3.bucket.access warning The anonymous identity doesn't need IAM actions — the bucket policy controls what anonymous users can do. * shell: only warn about anonymous identity when write access is set Read and list operations use AuthWithPublicRead which evaluates bucket policies directly without requiring the anonymous identity. Only write operations go through the normal auth flow that needs it. * shell: rewrite s3.bucket.access to use IAM actions instead of bucket policies Replace the bucket policy approach with direct IAM identity actions, matching the s3.configure pattern. The user is auto-created if it does not exist. Usage: s3.bucket.access -name -user anonymous -access Read,List s3.bucket.access -name -user anonymous -access none s3.bucket.access -name -user anonymous Actions are stored as "Action:bucket" on the identity, same as s3.configure -actions=Read -buckets=my-bucket. * shell: return flag parse errors instead of swallowing them * shell: normalize action names case-insensitively in s3.bucket.access Accept actions in any case (read, READ, Read) and normalize to canonical form (Read, Write, List, etc.) before storing. This matches the case-insensitive handling of "none" and avoids confusing rejections. --- weed/shell/command_s3_bucket_access.go | 205 ++++++++++++++++++++ weed/shell/command_s3_bucket_access_test.go | 119 ++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 weed/shell/command_s3_bucket_access.go create mode 100644 weed/shell/command_s3_bucket_access_test.go diff --git a/weed/shell/command_s3_bucket_access.go b/weed/shell/command_s3_bucket_access.go new file mode 100644 index 000000000..48a230ffb --- /dev/null +++ b/weed/shell/command_s3_bucket_access.go @@ -0,0 +1,205 @@ +package shell + +import ( + "bytes" + "context" + "flag" + "fmt" + "io" + "sort" + "strings" + + "github.com/seaweedfs/seaweedfs/weed/filer" + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// canonicalActions maps lowercased action names to their canonical form. +var canonicalActions = map[string]string{ + "read": "Read", + "write": "Write", + "list": "List", + "tagging": "Tagging", + "admin": "Admin", +} + +func init() { + Commands = append(Commands, &commandS3BucketAccess{}) +} + +type commandS3BucketAccess struct { +} + +func (c *commandS3BucketAccess) Name() string { + return "s3.bucket.access" +} + +func (c *commandS3BucketAccess) Help() string { + return `view or set per-bucket access for a user + + Example: + # View current access for a user on a bucket + s3.bucket.access -name -user + + # Grant anonymous read and list access + s3.bucket.access -name -user anonymous -access Read,List + + # Grant full anonymous 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 + + The user is auto-created if it does not exist. Actions are scoped to + the specified bucket (stored as "Action:bucket" in the identity). +` +} + +func (c *commandS3BucketAccess) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3BucketAccess) Do(args []string, commandEnv *CommandEnv, writer io.Writer) (err error) { + + 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") + if err = bucketCommand.Parse(args); err != nil { + return err + } + + if *bucketName == "" { + return fmt.Errorf("empty bucket name") + } + if *userName == "" { + return fmt.Errorf("empty user name") + } + + 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) + } + accessStr = strings.Join(normalized, ",") + } + + err = pb.WithGrpcClient(false, 0, func(conn *grpc.ClientConn) error { + client := iam_pb.NewSeaweedIdentityAccessManagementClient(conn) + + // Get or create user + identity, isNewUser, getErr := getOrCreateIdentity(client, *userName) + if getErr != nil { + return getErr + } + + // View mode: show current bucket-scoped actions + if accessStr == "" { + return displayBucketAccess(writer, *bucketName, *userName, identity) + } + + // Set mode: update actions + updateBucketActions(identity, *bucketName, accessStr) + + // Show the resulting identity + var buf bytes.Buffer + filer.ProtoToText(&buf, identity) + fmt.Fprint(writer, buf.String()) + fmt.Fprintln(writer) + + // Save + if isNewUser { + if _, err := client.CreateUser(context.Background(), &iam_pb.CreateUserRequest{Identity: identity}); err != nil { + return fmt.Errorf("failed to create user %s: %w", *userName, err) + } + fmt.Fprintf(writer, "Created user %q and set access on bucket %s.\n", *userName, *bucketName) + } else { + if _, err := client.UpdateUser(context.Background(), &iam_pb.UpdateUserRequest{Username: *userName, Identity: identity}); err != nil { + return fmt.Errorf("failed to update user %s: %w", *userName, err) + } + fmt.Fprintf(writer, "Updated access for user %q on bucket %s.\n", *userName, *bucketName) + } + return nil + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + + return err +} + +func getOrCreateIdentity(client iam_pb.SeaweedIdentityAccessManagementClient, userName string) (*iam_pb.Identity, bool, error) { + resp, getErr := client.GetUser(context.Background(), &iam_pb.GetUserRequest{ + Username: userName, + }) + if getErr == nil && resp.Identity != nil { + return resp.Identity, false, nil + } + + st, ok := status.FromError(getErr) + if ok && st.Code() == codes.NotFound { + return &iam_pb.Identity{ + Name: userName, + Credentials: []*iam_pb.Credential{}, + Actions: []string{}, + PolicyNames: []string{}, + }, true, nil + } + + return nil, false, fmt.Errorf("failed to get user %s: %v", userName, getErr) +} + +func displayBucketAccess(writer io.Writer, bucketName, userName string, identity *iam_pb.Identity) error { + suffix := ":" + bucketName + var actions []string + for _, a := range identity.Actions { + if strings.HasSuffix(a, suffix) { + actions = append(actions, strings.TrimSuffix(a, suffix)) + } + } + fmt.Fprintf(writer, "Bucket: %s\n", bucketName) + fmt.Fprintf(writer, "User: %s\n", userName) + if len(actions) == 0 { + fmt.Fprintln(writer, "Access: none") + } else { + sort.Strings(actions) + fmt.Fprintf(writer, "Access: %s\n", strings.Join(actions, ",")) + } + return nil +} + +// updateBucketActions removes existing actions for the bucket and adds the new ones. +func updateBucketActions(identity *iam_pb.Identity, bucketName, accessStr string) { + suffix := ":" + bucketName + + // Remove existing actions for this bucket + var kept []string + for _, a := range identity.Actions { + if !strings.HasSuffix(a, suffix) { + kept = append(kept, a) + } + } + + // Add new actions (unless "none") + if strings.ToLower(accessStr) != "none" { + for _, action := range strings.Split(accessStr, ",") { + action = strings.TrimSpace(action) + if action != "" { + kept = append(kept, action+suffix) + } + } + } + + identity.Actions = kept +} diff --git a/weed/shell/command_s3_bucket_access_test.go b/weed/shell/command_s3_bucket_access_test.go new file mode 100644 index 000000000..2bcd4d333 --- /dev/null +++ b/weed/shell/command_s3_bucket_access_test.go @@ -0,0 +1,119 @@ +package shell + +import ( + "bytes" + "slices" + "sort" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" +) + +func TestUpdateBucketActions_SetActions(t *testing.T) { + identity := &iam_pb.Identity{ + Name: "anonymous", + Actions: []string{}, + } + + updateBucketActions(identity, "my-bucket", "Read,List") + + expected := []string{"Read:my-bucket", "List:my-bucket"} + sort.Strings(identity.Actions) + sort.Strings(expected) + if !slices.Equal(identity.Actions, expected) { + t.Errorf("got %v, want %v", identity.Actions, expected) + } +} + +func TestUpdateBucketActions_ReplaceActions(t *testing.T) { + identity := &iam_pb.Identity{ + Name: "anonymous", + Actions: []string{"Read:my-bucket", "List:my-bucket"}, + } + + updateBucketActions(identity, "my-bucket", "Write") + + expected := []string{"Write: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", + Actions: []string{"Read:my-bucket", "List:my-bucket"}, + } + + updateBucketActions(identity, "my-bucket", "none") + + if len(identity.Actions) != 0 { + t.Errorf("expected empty actions, got %v", identity.Actions) + } +} + +func TestUpdateBucketActions_PreservesOtherBuckets(t *testing.T) { + identity := &iam_pb.Identity{ + Name: "testuser", + Actions: []string{"Read:bucket-a", "Write:bucket-b", "List:bucket-a"}, + } + + updateBucketActions(identity, "bucket-a", "Write") + + expected := []string{"Write:bucket-b", "Write:bucket-a"} + sort.Strings(identity.Actions) + sort.Strings(expected) + if !slices.Equal(identity.Actions, expected) { + t.Errorf("got %v, want %v", identity.Actions, expected) + } +} + +func TestUpdateBucketActions_PreservesGlobalActions(t *testing.T) { + identity := &iam_pb.Identity{ + Name: "testuser", + Actions: []string{"Admin", "Read:my-bucket"}, + } + + updateBucketActions(identity, "my-bucket", "none") + + expected := []string{"Admin"} + if !slices.Equal(identity.Actions, expected) { + t.Errorf("got %v, want %v", identity.Actions, expected) + } +} + +func TestDisplayBucketAccess(t *testing.T) { + identity := &iam_pb.Identity{ + Name: "anonymous", + Actions: []string{"Read:my-bucket", "List:my-bucket", "Write:other-bucket"}, + } + + var buf bytes.Buffer + err := displayBucketAccess(&buf, "my-bucket", "anonymous", identity) + if err != nil { + t.Fatal(err) + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("List,Read")) { + t.Errorf("expected 'List,Read' in output, got: %s", output) + } +} + +func TestDisplayBucketAccess_None(t *testing.T) { + identity := &iam_pb.Identity{ + Name: "anonymous", + Actions: []string{"Write:other-bucket"}, + } + + var buf bytes.Buffer + err := displayBucketAccess(&buf, "my-bucket", "anonymous", identity) + if err != nil { + t.Fatal(err) + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("Access: none")) { + t.Errorf("expected 'Access: none' in output, got: %s", output) + } +}