shell: add s3.bucket.access command for anonymous access policy (#8774)

* 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 <bucket> -access read,list
  s3.bucket.access -name <bucket> -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 <bucket> -user anonymous -access Read,List
  s3.bucket.access -name <bucket> -user anonymous -access none
  s3.bucket.access -name <bucket> -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.
This commit is contained in:
Chris Lu
2026-03-25 23:09:53 -07:00
committed by GitHub
parent 67a551fd62
commit ccc662b90b
2 changed files with 324 additions and 0 deletions

View File

@@ -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 <bucket_name> -user <username>
# Grant anonymous read and list access
s3.bucket.access -name <bucket_name> -user anonymous -access Read,List
# Grant full anonymous access
s3.bucket.access -name <bucket_name> -user anonymous -access Read,Write,List
# Remove all access for a user on a bucket
s3.bucket.access -name <bucket_name> -user <username> -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
}

View File

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