Add workflow-oriented S3 shell commands

This commit is contained in:
Chris Lu
2026-04-06 18:27:22 -07:00
parent 0fed72d95a
commit d4261c2271
5 changed files with 1200 additions and 5 deletions
+7 -2
View File
@@ -31,8 +31,13 @@ func (c *commandS3Configure) Name() string {
func (c *commandS3Configure) Help() string {
return `configure and apply s3 options for each bucket
# see the current configuration file content
s3.configure
# for workflow-oriented user management, prefer:
s3.user.create -name=username -generate_credentials
s3.user.show -name=username
s3.policy.attach -user=username -name=mypolicy
# see the current configuration file content
s3.configure
# 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
+11 -3
View File
@@ -29,10 +29,18 @@ func (c *commandS3Policy) Name() string {
func (c *commandS3Policy) Help() string {
return `manage s3 policies
# create or update a policy
s3.policy -put -name=mypolicy -file=policy.json
# preferred exact commands
s3.policy.create -name=mypolicy -file=policy.json
s3.policy.show -name=mypolicy
s3.policy.list
s3.policy.delete -name=mypolicy
s3.policy.attach -user=alice -name=mypolicy
s3.policy.detach -user=alice -name=mypolicy
# list all policies
# create or update a policy
s3.policy -put -name=mypolicy -file=policy.json
# list all policies
s3.policy -list
# get a policy
+282
View File
@@ -0,0 +1,282 @@
package shell
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
)
func init() {
Commands = append(Commands,
&s3ShellCommand{
name: "s3.policy.create",
help: `create or update a managed S3 policy
s3.policy.create -name photos-rw -file policy.json`,
do: runS3PolicyCreateCommand,
},
&s3ShellCommand{
name: "s3.policy.show",
help: `show one managed S3 policy
s3.policy.show -name photos-rw`,
do: runS3PolicyShowCommand,
},
&s3ShellCommand{
name: "s3.policy.list",
help: `list managed S3 policies`,
do: runS3PolicyListCommand,
},
&s3ShellCommand{
name: "s3.policy.delete",
help: `delete a managed S3 policy
s3.policy.delete -name photos-rw`,
do: runS3PolicyDeleteCommand,
},
&s3ShellCommand{
name: "s3.policy.attach",
help: `attach a managed S3 policy to a filer-backed user
s3.policy.attach -user alice -name photos-rw`,
do: runS3PolicyAttachCommand,
},
&s3ShellCommand{
name: "s3.policy.detach",
help: `detach a managed S3 policy from a filer-backed user
s3.policy.detach -user alice -name photos-rw`,
do: runS3PolicyDetachCommand,
},
)
}
func runS3PolicyCreateCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet("s3.policy.create", flag.ContinueOnError)
fs.SetOutput(writer)
name := fs.String("name", "", "policy name")
file := fs.String("file", "", "policy file (json)")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
if *name == "" {
return fmt.Errorf("-name is required")
}
if *file == "" {
return fmt.Errorf("-file is required")
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3PolicyCreate(ctx, store, *name, *file, writer)
})
}
func runS3PolicyShowCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet("s3.policy.show", flag.ContinueOnError)
fs.SetOutput(writer)
name := fs.String("name", "", "policy name")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
if *name == "" {
return fmt.Errorf("-name is required")
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3PolicyShow(ctx, store, *name, writer)
})
}
func runS3PolicyListCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet("s3.policy.list", flag.ContinueOnError)
fs.SetOutput(writer)
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3PolicyList(ctx, store, writer)
})
}
func runS3PolicyDeleteCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet("s3.policy.delete", flag.ContinueOnError)
fs.SetOutput(writer)
name := fs.String("name", "", "policy name")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
if *name == "" {
return fmt.Errorf("-name is required")
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3PolicyDelete(ctx, store, *name, writer)
})
}
func runS3PolicyAttachCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
return runS3PolicyAttachDetachCommand("s3.policy.attach", true, args, commandEnv, writer)
}
func runS3PolicyDetachCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
return runS3PolicyAttachDetachCommand("s3.policy.detach", false, args, commandEnv, writer)
}
func runS3PolicyAttachDetachCommand(commandName string, attach bool, args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet(commandName, flag.ContinueOnError)
fs.SetOutput(writer)
user := fs.String("user", "", "user name")
name := fs.String("name", "", "policy name")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
if *user == "" {
return fmt.Errorf("-user is required")
}
if *name == "" {
return fmt.Errorf("-name is required")
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
if attach {
return runS3PolicyAttach(ctx, store, *user, *name, writer)
}
return runS3PolicyDetach(ctx, store, *user, *name, writer)
})
}
func runS3PolicyCreate(ctx context.Context, store s3ShellStore, name, file string, writer io.Writer) error {
document, err := loadPolicyDocumentFromFile(file)
if err != nil {
return err
}
if err := store.CreatePolicy(ctx, name, *document); err != nil {
return err
}
fmt.Fprintf(writer, "Saved policy %q.\n", name)
return nil
}
func runS3PolicyShow(ctx context.Context, store s3ShellStore, name string, writer io.Writer) error {
document, err := store.GetPolicy(ctx, name)
if err != nil {
return err
}
if document == nil {
return fmt.Errorf("policy %q not found", name)
}
formatted, err := marshalPolicyDocument(document)
if err != nil {
return err
}
fmt.Fprintln(writer, formatted)
return nil
}
func runS3PolicyList(ctx context.Context, store s3ShellStore, writer io.Writer) error {
policies, err := store.GetPolicies(ctx)
if err != nil {
return err
}
names := make([]string, 0, len(policies))
for name := range policies {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
fmt.Fprintln(writer, name)
}
return nil
}
func runS3PolicyDelete(ctx context.Context, store s3ShellStore, name string, writer io.Writer) error {
if err := store.DeletePolicy(ctx, name); err != nil {
if s3ShellIsNotFound(err) {
return fmt.Errorf("policy %q not found", name)
}
return err
}
fmt.Fprintf(writer, "Deleted policy %q.\n", name)
return nil
}
func runS3PolicyAttach(ctx context.Context, store s3ShellStore, username, policyName string, writer io.Writer) error {
identity, err := getS3User(ctx, store, username)
if err != nil {
return err
}
if err := ensureMutableIdentity(identity, "attach policies to"); err != nil {
return err
}
if err := store.AttachUserPolicy(ctx, username, policyName); err != nil {
if errors.Is(err, credential.ErrPolicyNotFound) {
return fmt.Errorf("policy %q not found", policyName)
}
return err
}
fmt.Fprintf(writer, "Attached policy %q to user %q.\n", policyName, username)
return nil
}
func runS3PolicyDetach(ctx context.Context, store s3ShellStore, username, policyName string, writer io.Writer) error {
identity, err := getS3User(ctx, store, username)
if err != nil {
return err
}
if err := ensureMutableIdentity(identity, "detach policies from"); err != nil {
return err
}
if err := store.DetachUserPolicy(ctx, username, policyName); err != nil {
if errors.Is(err, credential.ErrPolicyNotAttached) {
return fmt.Errorf("policy %q is not attached to user %q", policyName, username)
}
return err
}
fmt.Fprintf(writer, "Detached policy %q from user %q.\n", policyName, username)
return nil
}
func loadPolicyDocumentFromFile(file string) (*policy_engine.PolicyDocument, error) {
data, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("failed to read policy file: %v", err)
}
var document policy_engine.PolicyDocument
if err := json.Unmarshal(data, &document); err != nil {
return nil, fmt.Errorf("invalid policy json: %v", err)
}
return &document, nil
}
func marshalPolicyDocument(document *policy_engine.PolicyDocument) (string, error) {
data, err := json.MarshalIndent(document, "", " ")
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
+709
View File
@@ -0,0 +1,709 @@
package shell
import (
"context"
"crypto/rand"
"errors"
"flag"
"fmt"
"io"
"sort"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/credential"
iamgrpc "github.com/seaweedfs/seaweedfs/weed/credential/grpc"
weediam "github.com/seaweedfs/seaweedfs/weed/iam"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
var errS3AccessKeyInUse = errors.New("access key already in use")
type s3ShellCommand struct {
name string
help string
do func([]string, *CommandEnv, io.Writer) error
}
func (c *s3ShellCommand) Name() string { return c.name }
func (c *s3ShellCommand) Help() string { return c.help }
func (c *s3ShellCommand) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error {
return c.do(args, commandEnv, writer)
}
func (c *s3ShellCommand) HasTag(CommandTag) bool { return false }
type s3ShellStore interface {
CreateUser(ctx context.Context, identity *iam_pb.Identity) error
GetUser(ctx context.Context, username string) (*iam_pb.Identity, error)
UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error
DeleteUser(ctx context.Context, username string) error
ListUsers(ctx context.Context) ([]string, error)
GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error)
CreateAccessKey(ctx context.Context, username string, credential *iam_pb.Credential) error
DeleteAccessKey(ctx context.Context, username string, accessKey string) error
GetPolicy(ctx context.Context, name string) (*policy_engine.PolicyDocument, error)
GetPolicies(ctx context.Context) (map[string]policy_engine.PolicyDocument, error)
CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error
DeletePolicy(ctx context.Context, name string) error
AttachUserPolicy(ctx context.Context, username string, policyName string) error
DetachUserPolicy(ctx context.Context, username string, policyName string) error
ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error)
}
type s3UserCreateOptions struct {
name string
accessKey string
secretKey string
generateCredentials bool
accountID string
displayName string
email string
}
type s3AccessKeyCreateOptions struct {
username string
accessKey string
secretKey string
generateCredentials bool
}
func init() {
Commands = append(Commands,
&s3ShellCommand{
name: "s3.user.list",
help: `list S3 users with source and status`,
do: runS3UserListCommand,
},
&s3ShellCommand{
name: "s3.user.show",
help: `show one S3 user's details
s3.user.show -name alice`,
do: runS3UserShowCommand,
},
&s3ShellCommand{
name: "s3.user.create",
help: `create a filer-backed S3 user
s3.user.create -name alice -generate_credentials
s3.user.create -name alice -access_key AKIA... -secret_key secret
s3.user.create -name alice -email alice@example.com -display_name "Alice"`,
do: runS3UserCreateCommand,
},
&s3ShellCommand{
name: "s3.user.delete",
help: `delete a filer-backed S3 user
s3.user.delete -name alice`,
do: runS3UserDeleteCommand,
},
&s3ShellCommand{
name: "s3.user.enable",
help: `enable a filer-backed S3 user
s3.user.enable -name alice`,
do: runS3UserEnableCommand,
},
&s3ShellCommand{
name: "s3.user.disable",
help: `disable a filer-backed S3 user
s3.user.disable -name alice`,
do: runS3UserDisableCommand,
},
&s3ShellCommand{
name: "s3.user.accesskey.list",
help: `list access keys for a filer-backed S3 user
s3.user.accesskey.list -user alice`,
do: runS3UserAccessKeyListCommand,
},
&s3ShellCommand{
name: "s3.user.accesskey.create",
help: `create an access key for a filer-backed S3 user
s3.user.accesskey.create -user alice -generate_credentials
s3.user.accesskey.create -user alice -access_key AKIA... -secret_key secret`,
do: runS3UserAccessKeyCreateCommand,
},
&s3ShellCommand{
name: "s3.user.accesskey.delete",
help: `delete an access key for a filer-backed S3 user
s3.user.accesskey.delete -user alice -access_key AKIA...`,
do: runS3UserAccessKeyDeleteCommand,
},
)
}
func runS3UserListCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet("s3.user.list", flag.ContinueOnError)
fs.SetOutput(writer)
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3UserList(ctx, store, writer)
})
}
func runS3UserShowCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet("s3.user.show", flag.ContinueOnError)
fs.SetOutput(writer)
name := fs.String("name", "", "user name")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
if *name == "" {
return fmt.Errorf("-name is required")
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3UserShow(ctx, store, *name, writer)
})
}
func runS3UserCreateCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet("s3.user.create", flag.ContinueOnError)
fs.SetOutput(writer)
opts := s3UserCreateOptions{}
fs.StringVar(&opts.name, "name", "", "user name")
fs.StringVar(&opts.accessKey, "access_key", "", "access key to create")
fs.StringVar(&opts.secretKey, "secret_key", "", "secret key to create")
fs.BoolVar(&opts.generateCredentials, "generate_credentials", false, "generate an initial access key pair")
fs.StringVar(&opts.accountID, "account_id", "", "account id")
fs.StringVar(&opts.displayName, "display_name", "", "account display name")
fs.StringVar(&opts.email, "email", "", "account email")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
if opts.name == "" {
return fmt.Errorf("-name is required")
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3UserCreate(ctx, store, opts, writer)
})
}
func runS3UserDeleteCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet("s3.user.delete", flag.ContinueOnError)
fs.SetOutput(writer)
name := fs.String("name", "", "user name")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
if *name == "" {
return fmt.Errorf("-name is required")
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3UserDelete(ctx, store, *name, writer)
})
}
func runS3UserEnableCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
return runS3UserStatusCommand("s3.user.enable", false, args, commandEnv, writer)
}
func runS3UserDisableCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
return runS3UserStatusCommand("s3.user.disable", true, args, commandEnv, writer)
}
func runS3UserStatusCommand(commandName string, disabled bool, args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet(commandName, flag.ContinueOnError)
fs.SetOutput(writer)
name := fs.String("name", "", "user name")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
if *name == "" {
return fmt.Errorf("-name is required")
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3UserSetDisabled(ctx, store, *name, disabled, writer)
})
}
func runS3UserAccessKeyListCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet("s3.user.accesskey.list", flag.ContinueOnError)
fs.SetOutput(writer)
user := fs.String("user", "", "user name")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
if *user == "" {
return fmt.Errorf("-user is required")
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3UserAccessKeyList(ctx, store, *user, writer)
})
}
func runS3UserAccessKeyCreateCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet("s3.user.accesskey.create", flag.ContinueOnError)
fs.SetOutput(writer)
opts := s3AccessKeyCreateOptions{}
fs.StringVar(&opts.username, "user", "", "user name")
fs.StringVar(&opts.accessKey, "access_key", "", "access key to create")
fs.StringVar(&opts.secretKey, "secret_key", "", "secret key to create")
fs.BoolVar(&opts.generateCredentials, "generate_credentials", false, "generate an access key pair")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
if opts.username == "" {
return fmt.Errorf("-user is required")
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3UserAccessKeyCreate(ctx, store, opts, writer)
})
}
func runS3UserAccessKeyDeleteCommand(args []string, commandEnv *CommandEnv, writer io.Writer) error {
fs := flag.NewFlagSet("s3.user.accesskey.delete", flag.ContinueOnError)
fs.SetOutput(writer)
user := fs.String("user", "", "user name")
accessKey := fs.String("access_key", "", "access key to delete")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil
}
return err
}
if *user == "" {
return fmt.Errorf("-user is required")
}
if *accessKey == "" {
return fmt.Errorf("-access_key is required")
}
return withS3ShellStore(commandEnv, func(ctx context.Context, store s3ShellStore) error {
return runS3UserAccessKeyDelete(ctx, store, *user, *accessKey, writer)
})
}
func runS3UserList(ctx context.Context, store s3ShellStore, writer io.Writer) error {
usernames, err := store.ListUsers(ctx)
if err != nil {
return err
}
sort.Strings(usernames)
fmt.Fprintln(writer, "NAME\tSOURCE\tSTATUS\tACCESS KEYS\tPOLICIES")
for _, username := range usernames {
identity, err := store.GetUser(ctx, username)
if err != nil {
return err
}
fmt.Fprintf(writer, "%s\t%s\t%s\t%d\t%d\n",
identity.Name,
s3IdentitySource(identity),
s3IdentityStatus(identity),
len(identity.Credentials),
len(identity.PolicyNames),
)
}
return nil
}
func runS3UserShow(ctx context.Context, store s3ShellStore, username string, writer io.Writer) error {
identity, err := getS3User(ctx, store, username)
if err != nil {
return err
}
writeS3IdentityDetails(writer, identity)
return nil
}
func runS3UserCreate(ctx context.Context, store s3ShellStore, opts s3UserCreateOptions, writer io.Writer) error {
existing, err := store.GetUser(ctx, opts.name)
switch {
case err == nil && existing != nil:
if existing.IsStatic {
return fmt.Errorf("user %q already exists in -s3.config; edit the static config for bootstrap users", opts.name)
}
return fmt.Errorf("user %q already exists", opts.name)
case err != nil && !s3ShellIsNotFound(err):
return err
}
identity := &iam_pb.Identity{Name: opts.name}
if opts.accountID != "" || opts.displayName != "" || opts.email != "" {
identity.Account = &iam_pb.Account{
Id: opts.accountID,
DisplayName: opts.displayName,
EmailAddress: opts.email,
}
}
createdCredential, err := s3BuildCredentialForCreate(ctx, store, opts.accessKey, opts.secretKey, opts.generateCredentials || opts.accessKey != "" || opts.secretKey != "")
if err != nil {
return err
}
if createdCredential != nil {
identity.Credentials = []*iam_pb.Credential{createdCredential}
}
if err := store.CreateUser(ctx, identity); err != nil {
if s3ShellIsAlreadyExists(err) {
return fmt.Errorf("user %q already exists", opts.name)
}
return err
}
fmt.Fprintf(writer, "Created user %q.\n", opts.name)
if createdCredential != nil {
writeCreatedCredential(writer, createdCredential)
}
return nil
}
func runS3UserDelete(ctx context.Context, store s3ShellStore, username string, writer io.Writer) error {
identity, err := getS3User(ctx, store, username)
if err != nil {
return err
}
if err := ensureMutableIdentity(identity, "delete"); err != nil {
return err
}
if err := store.DeleteUser(ctx, username); err != nil {
if s3ShellIsNotFound(err) {
return fmt.Errorf("user %q not found", username)
}
return err
}
fmt.Fprintf(writer, "Deleted user %q.\n", username)
return nil
}
func runS3UserSetDisabled(ctx context.Context, store s3ShellStore, username string, disabled bool, writer io.Writer) error {
identity, err := getS3User(ctx, store, username)
if err != nil {
return err
}
if err := ensureMutableIdentity(identity, ternary(disabled, "disable", "enable")); err != nil {
return err
}
if identity.Disabled == disabled {
fmt.Fprintf(writer, "User %q is already %s.\n", username, s3IdentityStatus(identity))
return nil
}
updated := proto.Clone(identity).(*iam_pb.Identity)
updated.Disabled = disabled
if err := store.UpdateUser(ctx, username, updated); err != nil {
return err
}
if disabled {
fmt.Fprintf(writer, "Disabled user %q.\n", username)
} else {
fmt.Fprintf(writer, "Enabled user %q.\n", username)
}
return nil
}
func runS3UserAccessKeyList(ctx context.Context, store s3ShellStore, username string, writer io.Writer) error {
identity, err := getS3User(ctx, store, username)
if err != nil {
return err
}
fmt.Fprintf(writer, "USER\tACCESS KEY\tSTATUS\n")
for _, credential := range sortedCredentials(identity.Credentials) {
fmt.Fprintf(writer, "%s\t%s\t%s\n", username, credential.AccessKey, s3CredentialStatus(credential))
}
if len(identity.Credentials) == 0 {
fmt.Fprintf(writer, "%s\t%s\t%s\n", username, "-", "-")
}
return nil
}
func runS3UserAccessKeyCreate(ctx context.Context, store s3ShellStore, opts s3AccessKeyCreateOptions, writer io.Writer) error {
identity, err := getS3User(ctx, store, opts.username)
if err != nil {
return err
}
if err := ensureMutableIdentity(identity, "create access keys for"); err != nil {
return err
}
createdCredential, err := s3BuildCredentialForCreate(ctx, store, opts.accessKey, opts.secretKey, opts.generateCredentials || opts.accessKey != "" || opts.secretKey != "")
if err != nil {
return err
}
if createdCredential == nil {
return fmt.Errorf("set -generate_credentials or provide -access_key and/or -secret_key")
}
if err := store.CreateAccessKey(ctx, opts.username, createdCredential); err != nil {
if s3ShellIsAlreadyExists(err) {
return fmt.Errorf("access key %q is already in use", createdCredential.AccessKey)
}
return err
}
fmt.Fprintf(writer, "Created access key for user %q.\n", opts.username)
writeCreatedCredential(writer, createdCredential)
return nil
}
func runS3UserAccessKeyDelete(ctx context.Context, store s3ShellStore, username string, accessKey string, writer io.Writer) error {
identity, err := getS3User(ctx, store, username)
if err != nil {
return err
}
if err := ensureMutableIdentity(identity, "delete access keys for"); err != nil {
return err
}
if err := store.DeleteAccessKey(ctx, username, accessKey); err != nil {
if s3ShellIsNotFound(err) {
return fmt.Errorf("access key %q not found for user %q", accessKey, username)
}
return err
}
fmt.Fprintf(writer, "Deleted access key %q for user %q.\n", accessKey, username)
return nil
}
func withS3ShellStore(commandEnv *CommandEnv, fn func(ctx context.Context, store s3ShellStore) error) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
store := &iamgrpc.IamGrpcStore{}
store.SetFilerAddressFunc(func() pb.ServerAddress {
return commandEnv.option.FilerAddress
}, commandEnv.option.GrpcDialOption)
return fn(ctx, store)
}
func ensureMutableIdentity(identity *iam_pb.Identity, action string) error {
if identity != nil && identity.IsStatic {
return fmt.Errorf("cannot %s user %q because it comes from -s3.config; edit the static config for bootstrap users", action, identity.Name)
}
return nil
}
func getS3User(ctx context.Context, store s3ShellStore, username string) (*iam_pb.Identity, error) {
identity, err := store.GetUser(ctx, username)
if err != nil {
if s3ShellIsNotFound(err) {
return nil, fmt.Errorf("user %q not found", username)
}
return nil, err
}
return identity, nil
}
func s3BuildCredentialForCreate(ctx context.Context, store s3ShellStore, accessKey, secretKey string, shouldCreate bool) (*iam_pb.Credential, error) {
if !shouldCreate {
return nil, nil
}
if accessKey == "" {
var err error
accessKey, err = generateUniqueAccessKey(ctx, store)
if err != nil {
return nil, err
}
} else {
if err := ensureAccessKeyAvailable(ctx, store, accessKey); err != nil {
return nil, err
}
}
if secretKey == "" {
var err error
secretKey, err = weediam.GenerateSecretAccessKey()
if err != nil {
return nil, fmt.Errorf("generate secret key: %w", err)
}
}
return &iam_pb.Credential{
AccessKey: accessKey,
SecretKey: secretKey,
Status: weediam.AccessKeyStatusActive,
}, nil
}
func ensureAccessKeyAvailable(ctx context.Context, store s3ShellStore, accessKey string) error {
_, err := store.GetUserByAccessKey(ctx, accessKey)
if err == nil {
return fmt.Errorf("%w: %s", errS3AccessKeyInUse, accessKey)
}
if s3ShellIsNotFound(err) {
return nil
}
return err
}
func generateUniqueAccessKey(ctx context.Context, store s3ShellStore) (string, error) {
for range 16 {
accessKey, err := generateAccessKey()
if err != nil {
return "", err
}
if err := ensureAccessKeyAvailable(ctx, store, accessKey); err == nil {
return accessKey, nil
} else if !errors.Is(err, errS3AccessKeyInUse) {
return "", err
}
}
return "", fmt.Errorf("failed to generate a unique access key")
}
func generateAccessKey() (string, error) {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var raw [20]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", fmt.Errorf("generate access key: %w", err)
}
for i := range raw {
raw[i] = charset[int(raw[i])%len(charset)]
}
return string(raw[:]), nil
}
func s3IdentitySource(identity *iam_pb.Identity) string {
if identity != nil && identity.IsStatic {
return "static"
}
return "dynamic"
}
func s3IdentityStatus(identity *iam_pb.Identity) string {
if identity != nil && identity.Disabled {
return "disabled"
}
return "enabled"
}
func s3CredentialStatus(credential *iam_pb.Credential) string {
if credential == nil || credential.Status == "" {
return weediam.AccessKeyStatusActive
}
return credential.Status
}
func sortedCredentials(credentials []*iam_pb.Credential) []*iam_pb.Credential {
out := append([]*iam_pb.Credential(nil), credentials...)
sort.Slice(out, func(i, j int) bool {
return out[i].AccessKey < out[j].AccessKey
})
return out
}
func writeCreatedCredential(writer io.Writer, credential *iam_pb.Credential) {
fmt.Fprintf(writer, "Access Key: %s\n", credential.AccessKey)
fmt.Fprintf(writer, "Secret Key: %s\n", credential.SecretKey)
}
func writeS3IdentityDetails(writer io.Writer, identity *iam_pb.Identity) {
fmt.Fprintf(writer, "Name: %s\n", identity.Name)
fmt.Fprintf(writer, "Source: %s\n", s3IdentitySource(identity))
fmt.Fprintf(writer, "Status: %s\n", s3IdentityStatus(identity))
if identity.Account != nil {
if identity.Account.Id != "" {
fmt.Fprintf(writer, "Account ID: %s\n", identity.Account.Id)
}
if identity.Account.DisplayName != "" {
fmt.Fprintf(writer, "Display Name: %s\n", identity.Account.DisplayName)
}
if identity.Account.EmailAddress != "" {
fmt.Fprintf(writer, "Email: %s\n", identity.Account.EmailAddress)
}
}
if len(identity.Credentials) == 0 {
fmt.Fprintln(writer, "Access Keys: none")
} else {
fmt.Fprintln(writer, "Access Keys:")
for _, credential := range sortedCredentials(identity.Credentials) {
fmt.Fprintf(writer, " %s (%s)\n", credential.AccessKey, s3CredentialStatus(credential))
}
}
if len(identity.PolicyNames) == 0 {
fmt.Fprintln(writer, "Policies: none")
} else {
policies := append([]string(nil), identity.PolicyNames...)
sort.Strings(policies)
fmt.Fprintf(writer, "Policies: %s\n", strings.Join(policies, ", "))
}
if len(identity.Actions) == 0 {
fmt.Fprintln(writer, "Actions: none")
} else {
actions := append([]string(nil), identity.Actions...)
sort.Strings(actions)
fmt.Fprintf(writer, "Actions: %s\n", strings.Join(actions, ", "))
}
}
func s3ShellIsNotFound(err error) bool {
if err == nil {
return false
}
if errors.Is(err, credential.ErrUserNotFound) ||
errors.Is(err, credential.ErrAccessKeyNotFound) ||
errors.Is(err, credential.ErrPolicyNotFound) {
return true
}
if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "not found")
}
func s3ShellIsAlreadyExists(err error) bool {
if err == nil {
return false
}
if errors.Is(err, credential.ErrUserAlreadyExists) {
return true
}
if st, ok := status.FromError(err); ok && st.Code() == codes.AlreadyExists {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "already exists")
}
func ternary[T any](cond bool, onTrue, onFalse T) T {
if cond {
return onTrue
}
return onFalse
}
@@ -0,0 +1,191 @@
package shell
import (
"bytes"
"context"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
weediam "github.com/seaweedfs/seaweedfs/weed/iam"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
)
func newTestS3ShellStore(t *testing.T) *memory.MemoryStore {
t.Helper()
store := &memory.MemoryStore{}
if err := store.Initialize(nil, ""); err != nil {
t.Fatalf("initialize memory store: %v", err)
}
return store
}
func TestRunS3UserCreateGeneratesCredentials(t *testing.T) {
store := newTestS3ShellStore(t)
var out bytes.Buffer
err := runS3UserCreate(context.Background(), store, s3UserCreateOptions{
name: "alice",
generateCredentials: true,
email: "alice@example.com",
}, &out)
if err != nil {
t.Fatalf("runS3UserCreate: %v", err)
}
user, err := store.GetUser(context.Background(), "alice")
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if len(user.Credentials) != 1 {
t.Fatalf("expected 1 credential, got %d", len(user.Credentials))
}
if got := user.Credentials[0].Status; got != weediam.AccessKeyStatusActive {
t.Fatalf("expected %q status, got %q", weediam.AccessKeyStatusActive, got)
}
if user.Account == nil || user.Account.EmailAddress != "alice@example.com" {
t.Fatalf("expected account email to be set, got %+v", user.Account)
}
output := out.String()
if !strings.Contains(output, `Created user "alice".`) {
t.Fatalf("expected created message, got %q", output)
}
if !strings.Contains(output, "Access Key:") || !strings.Contains(output, "Secret Key:") {
t.Fatalf("expected credential output, got %q", output)
}
}
func TestRunS3UserSetDisabledRejectsStaticUser(t *testing.T) {
store := newTestS3ShellStore(t)
if err := store.CreateUser(context.Background(), &iam_pb.Identity{
Name: "bootstrap-admin",
IsStatic: true,
}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
err := runS3UserSetDisabled(context.Background(), store, "bootstrap-admin", true, &bytes.Buffer{})
if err == nil {
t.Fatal("expected static user mutation to fail")
}
if !strings.Contains(err.Error(), "-s3.config") {
t.Fatalf("expected static user guidance, got %v", err)
}
}
func TestRunS3UserListIncludesSourceAndStatus(t *testing.T) {
store := newTestS3ShellStore(t)
if err := store.CreateUser(context.Background(), &iam_pb.Identity{
Name: "alice",
Credentials: []*iam_pb.Credential{
{AccessKey: "ALICEKEY", SecretKey: "secret", Status: weediam.AccessKeyStatusActive},
},
}); err != nil {
t.Fatalf("CreateUser alice: %v", err)
}
if err := store.CreateUser(context.Background(), &iam_pb.Identity{
Name: "bootstrap-admin",
IsStatic: true,
Disabled: true,
}); err != nil {
t.Fatalf("CreateUser bootstrap-admin: %v", err)
}
var out bytes.Buffer
if err := runS3UserList(context.Background(), store, &out); err != nil {
t.Fatalf("runS3UserList: %v", err)
}
output := out.String()
if !strings.Contains(output, "NAME\tSOURCE\tSTATUS\tACCESS KEYS\tPOLICIES") {
t.Fatalf("expected header, got %q", output)
}
if !strings.Contains(output, "alice\tdynamic\tenabled\t1\t0") {
t.Fatalf("expected dynamic user row, got %q", output)
}
if !strings.Contains(output, "bootstrap-admin\tstatic\tdisabled\t0\t0") {
t.Fatalf("expected static user row, got %q", output)
}
}
func TestRunS3UserAccessKeyCreateGeneratesCredential(t *testing.T) {
store := newTestS3ShellStore(t)
if err := store.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
var out bytes.Buffer
err := runS3UserAccessKeyCreate(context.Background(), store, s3AccessKeyCreateOptions{
username: "alice",
generateCredentials: true,
}, &out)
if err != nil {
t.Fatalf("runS3UserAccessKeyCreate: %v", err)
}
user, err := store.GetUser(context.Background(), "alice")
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if len(user.Credentials) != 1 {
t.Fatalf("expected 1 credential, got %d", len(user.Credentials))
}
if !strings.Contains(out.String(), `Created access key for user "alice".`) {
t.Fatalf("expected create message, got %q", out.String())
}
}
func TestRunS3PolicyAttachUpdatesUserPolicies(t *testing.T) {
store := newTestS3ShellStore(t)
if err := store.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := store.CreatePolicy(context.Background(), "photos-rw", policy_engine.PolicyDocument{
Version: "2012-10-17",
}); err != nil {
t.Fatalf("CreatePolicy: %v", err)
}
var out bytes.Buffer
if err := runS3PolicyAttach(context.Background(), store, "alice", "photos-rw", &out); err != nil {
t.Fatalf("runS3PolicyAttach: %v", err)
}
user, err := store.GetUser(context.Background(), "alice")
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if len(user.PolicyNames) != 1 || user.PolicyNames[0] != "photos-rw" {
t.Fatalf("expected attached policy, got %v", user.PolicyNames)
}
if !strings.Contains(out.String(), `Attached policy "photos-rw" to user "alice".`) {
t.Fatalf("expected attach message, got %q", out.String())
}
}
func TestRunS3PolicyDetachRejectsStaticUser(t *testing.T) {
store := newTestS3ShellStore(t)
if err := store.CreateUser(context.Background(), &iam_pb.Identity{
Name: "bootstrap-admin",
IsStatic: true,
PolicyNames: []string{"photos-rw"},
}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := store.CreatePolicy(context.Background(), "photos-rw", policy_engine.PolicyDocument{
Version: "2012-10-17",
}); err != nil {
t.Fatalf("CreatePolicy: %v", err)
}
err := runS3PolicyDetach(context.Background(), store, "bootstrap-admin", "photos-rw", &bytes.Buffer{})
if err == nil {
t.Fatal("expected static user mutation to fail")
}
if !strings.Contains(err.Error(), "-s3.config") {
t.Fatalf("expected static user guidance, got %v", err)
}
}