diff --git a/weed/shell/command_s3_configure.go b/weed/shell/command_s3_configure.go index c650e910d..82379d85f 100644 --- a/weed/shell/command_s3_configure.go +++ b/weed/shell/command_s3_configure.go @@ -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 diff --git a/weed/shell/command_s3_policy.go b/weed/shell/command_s3_policy.go index 1cd30b187..f4a33b4c7 100644 --- a/weed/shell/command_s3_policy.go +++ b/weed/shell/command_s3_policy.go @@ -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 diff --git a/weed/shell/command_s3_policy_subcommands.go b/weed/shell/command_s3_policy_subcommands.go new file mode 100644 index 000000000..e6de8fc26 --- /dev/null +++ b/weed/shell/command_s3_policy_subcommands.go @@ -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 +} diff --git a/weed/shell/command_s3_user_subcommands.go b/weed/shell/command_s3_user_subcommands.go new file mode 100644 index 000000000..e7f6fa3fe --- /dev/null +++ b/weed/shell/command_s3_user_subcommands.go @@ -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 +} diff --git a/weed/shell/command_s3_user_subcommands_test.go b/weed/shell/command_s3_user_subcommands_test.go new file mode 100644 index 000000000..b355c9dda --- /dev/null +++ b/weed/shell/command_s3_user_subcommands_test.go @@ -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) + } +}