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) + } +}