diff --git a/weed/shell/command_s3_policy_attach.go b/weed/shell/command_s3_policy_attach.go new file mode 100644 index 000000000..b170d6e56 --- /dev/null +++ b/weed/shell/command_s3_policy_attach.go @@ -0,0 +1,94 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" +) + +func init() { + Commands = append(Commands, &commandS3PolicyAttach{}) +} + +type commandS3PolicyAttach struct { +} + +func (c *commandS3PolicyAttach) Name() string { + return "s3.policy.attach" +} + +func (c *commandS3PolicyAttach) Help() string { + return `attach a policy to an S3 IAM user + + s3.policy.attach -policy -user + + The policy must already exist (create it with s3.policy -put). +` +} + +func (c *commandS3PolicyAttach) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3PolicyAttach) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + policy := f.String("policy", "", "policy name") + user := f.String("user", "", "user name") + if err := f.Parse(args); err != nil { + return err + } + + if *policy == "" { + return fmt.Errorf("-policy is required") + } + if *user == "" { + return fmt.Errorf("-user is required") + } + + return pb.WithGrpcClient(false, 0, func(conn *grpc.ClientConn) error { + client := iam_pb.NewSeaweedIdentityAccessManagementClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Verify the policy exists + _, err := client.GetPolicy(ctx, &iam_pb.GetPolicyRequest{Name: *policy}) + if err != nil { + return fmt.Errorf("get policy %q: %w", *policy, err) + } + + // Get the user + resp, err := client.GetUser(ctx, &iam_pb.GetUserRequest{Username: *user}) + if err != nil { + return fmt.Errorf("get user %q: %w", *user, err) + } + if resp.Identity == nil { + return fmt.Errorf("user %q returned empty identity", *user) + } + + // Check if already attached + for _, p := range resp.Identity.PolicyNames { + if p == *policy { + fmt.Fprintf(writer, "Policy %q is already attached to user %q.\n", *policy, *user) + return nil + } + } + + resp.Identity.PolicyNames = append(resp.Identity.PolicyNames, *policy) + _, err = client.UpdateUser(ctx, &iam_pb.UpdateUserRequest{ + Username: *user, + Identity: resp.Identity, + }) + if err != nil { + return err + } + + fmt.Fprintf(writer, "Attached policy %q to user %q\n", *policy, *user) + return nil + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +} diff --git a/weed/shell/command_s3_policy_detach.go b/weed/shell/command_s3_policy_detach.go new file mode 100644 index 000000000..d66896f29 --- /dev/null +++ b/weed/shell/command_s3_policy_detach.go @@ -0,0 +1,90 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" +) + +func init() { + Commands = append(Commands, &commandS3PolicyDetach{}) +} + +type commandS3PolicyDetach struct { +} + +func (c *commandS3PolicyDetach) Name() string { + return "s3.policy.detach" +} + +func (c *commandS3PolicyDetach) Help() string { + return `detach a policy from an S3 IAM user + + s3.policy.detach -policy -user +` +} + +func (c *commandS3PolicyDetach) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3PolicyDetach) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + policy := f.String("policy", "", "policy name") + user := f.String("user", "", "user name") + if err := f.Parse(args); err != nil { + return err + } + + if *policy == "" { + return fmt.Errorf("-policy is required") + } + if *user == "" { + return fmt.Errorf("-user is required") + } + + return pb.WithGrpcClient(false, 0, func(conn *grpc.ClientConn) error { + client := iam_pb.NewSeaweedIdentityAccessManagementClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := client.GetUser(ctx, &iam_pb.GetUserRequest{Username: *user}) + if err != nil { + return fmt.Errorf("get user %q: %w", *user, err) + } + if resp.Identity == nil { + return fmt.Errorf("user %q returned empty identity", *user) + } + + found := false + var kept []string + for _, p := range resp.Identity.PolicyNames { + if p == *policy { + found = true + } else { + kept = append(kept, p) + } + } + if !found { + return fmt.Errorf("policy %q is not attached to user %q", *policy, *user) + } + + resp.Identity.PolicyNames = kept + _, err = client.UpdateUser(ctx, &iam_pb.UpdateUserRequest{ + Username: *user, + Identity: resp.Identity, + }) + if err != nil { + return err + } + + fmt.Fprintf(writer, "Detached policy %q from user %q\n", *policy, *user) + return nil + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +} diff --git a/weed/shell/command_s3_user_create.go b/weed/shell/command_s3_user_create.go new file mode 100644 index 000000000..c9a602213 --- /dev/null +++ b/weed/shell/command_s3_user_create.go @@ -0,0 +1,102 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "time" + + "github.com/seaweedfs/seaweedfs/weed/iam" + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" +) + +func init() { + Commands = append(Commands, &commandS3UserCreate{}) +} + +type commandS3UserCreate struct { +} + +func (c *commandS3UserCreate) Name() string { + return "s3.user.create" +} + +func (c *commandS3UserCreate) Help() string { + return `create an S3 IAM user + + s3.user.create -name + s3.user.create -name -access_key -secret_key + + Creates a new user with a credential pair. If -access_key and -secret_key + are omitted, they are generated automatically. + + After creating a user, attach policies with s3.policy.attach. +` +} + +func (c *commandS3UserCreate) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3UserCreate) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + name := f.String("name", "", "user name") + accessKey := f.String("access_key", "", "access key (generated if omitted)") + secretKey := f.String("secret_key", "", "secret key (generated if omitted)") + if err := f.Parse(args); err != nil { + return err + } + + if *name == "" { + return fmt.Errorf("-name is required") + } + + ak := *accessKey + sk := *secretKey + + if ak == "" && sk == "" { + var err error + ak, err = iam.GenerateRandomString(iam.AccessKeyIdLength, iam.CharsetUpper) + if err != nil { + return fmt.Errorf("generate access key: %v", err) + } + sk, err = iam.GenerateSecretAccessKey() + if err != nil { + return fmt.Errorf("generate secret key: %v", err) + } + } else if ak == "" || sk == "" { + return fmt.Errorf("both -access_key and -secret_key must be provided together, or omit both to auto-generate") + } + + identity := &iam_pb.Identity{ + Name: *name, + Credentials: []*iam_pb.Credential{ + { + AccessKey: ak, + SecretKey: sk, + Status: iam.AccessKeyStatusActive, + }, + }, + } + + err := pb.WithGrpcClient(false, 0, func(conn *grpc.ClientConn) error { + client := iam_pb.NewSeaweedIdentityAccessManagementClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _, err := client.CreateUser(ctx, &iam_pb.CreateUserRequest{Identity: identity}) + return err + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + if err != nil { + return err + } + + fmt.Fprintf(writer, "Created user %q\n", *name) + fmt.Fprintf(writer, "Access Key: %s\n", ak) + fmt.Fprintf(writer, "Secret Key: %s\n", sk) + fmt.Fprintln(writer) + fmt.Fprintln(writer, "Save these credentials - the secret key cannot be retrieved later.") + return nil +} diff --git a/weed/shell/command_s3_user_delete.go b/weed/shell/command_s3_user_delete.go new file mode 100644 index 000000000..b74435888 --- /dev/null +++ b/weed/shell/command_s3_user_delete.go @@ -0,0 +1,61 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" +) + +func init() { + Commands = append(Commands, &commandS3UserDelete{}) +} + +type commandS3UserDelete struct { +} + +func (c *commandS3UserDelete) Name() string { + return "s3.user.delete" +} + +func (c *commandS3UserDelete) Help() string { + return `delete an S3 IAM user + + s3.user.delete -name +` +} + +func (c *commandS3UserDelete) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3UserDelete) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + name := f.String("name", "", "user name") + if err := f.Parse(args); err != nil { + return err + } + + if *name == "" { + return fmt.Errorf("-name is required") + } + + err := pb.WithGrpcClient(false, 0, func(conn *grpc.ClientConn) error { + client := iam_pb.NewSeaweedIdentityAccessManagementClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _, err := client.DeleteUser(ctx, &iam_pb.DeleteUserRequest{Username: *name}) + return err + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + if err != nil { + return err + } + + fmt.Fprintf(writer, "Deleted user %q\n", *name) + return nil +} diff --git a/weed/shell/command_s3_user_disable.go b/weed/shell/command_s3_user_disable.go new file mode 100644 index 000000000..3799460be --- /dev/null +++ b/weed/shell/command_s3_user_disable.go @@ -0,0 +1,82 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" +) + +func init() { + Commands = append(Commands, &commandS3UserDisable{}) +} + +type commandS3UserDisable struct { +} + +func (c *commandS3UserDisable) Name() string { + return "s3.user.disable" +} + +func (c *commandS3UserDisable) Help() string { + return `disable an S3 IAM user + + s3.user.disable -name + + Disabled users cannot authenticate. Their credentials and policies + are preserved and will take effect again when the user is re-enabled. +` +} + +func (c *commandS3UserDisable) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3UserDisable) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + name := f.String("name", "", "user name") + if err := f.Parse(args); err != nil { + return err + } + + if *name == "" { + return fmt.Errorf("-name is required") + } + + err := pb.WithGrpcClient(false, 0, func(conn *grpc.ClientConn) error { + client := iam_pb.NewSeaweedIdentityAccessManagementClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := client.GetUser(ctx, &iam_pb.GetUserRequest{Username: *name}) + if err != nil { + return fmt.Errorf("get user %q: %w", *name, err) + } + if resp.Identity == nil { + return fmt.Errorf("user %q returned empty identity", *name) + } + + if resp.Identity.Disabled { + fmt.Fprintf(writer, "User %q is already disabled.\n", *name) + return nil + } + + resp.Identity.Disabled = true + _, err = client.UpdateUser(ctx, &iam_pb.UpdateUserRequest{ + Username: *name, + Identity: resp.Identity, + }) + return err + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + if err != nil { + return err + } + + fmt.Fprintf(writer, "Disabled user %q\n", *name) + return nil +} diff --git a/weed/shell/command_s3_user_enable.go b/weed/shell/command_s3_user_enable.go new file mode 100644 index 000000000..76a3635ed --- /dev/null +++ b/weed/shell/command_s3_user_enable.go @@ -0,0 +1,79 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" +) + +func init() { + Commands = append(Commands, &commandS3UserEnable{}) +} + +type commandS3UserEnable struct { +} + +func (c *commandS3UserEnable) Name() string { + return "s3.user.enable" +} + +func (c *commandS3UserEnable) Help() string { + return `enable a disabled S3 IAM user + + s3.user.enable -name +` +} + +func (c *commandS3UserEnable) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3UserEnable) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + name := f.String("name", "", "user name") + if err := f.Parse(args); err != nil { + return err + } + + if *name == "" { + return fmt.Errorf("-name is required") + } + + err := pb.WithGrpcClient(false, 0, func(conn *grpc.ClientConn) error { + client := iam_pb.NewSeaweedIdentityAccessManagementClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := client.GetUser(ctx, &iam_pb.GetUserRequest{Username: *name}) + if err != nil { + return fmt.Errorf("get user %q: %w", *name, err) + } + if resp.Identity == nil { + return fmt.Errorf("user %q returned empty identity", *name) + } + + if !resp.Identity.Disabled { + fmt.Fprintf(writer, "User %q is already enabled.\n", *name) + return nil + } + + resp.Identity.Disabled = false + _, err = client.UpdateUser(ctx, &iam_pb.UpdateUserRequest{ + Username: *name, + Identity: resp.Identity, + }) + return err + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + if err != nil { + return err + } + + fmt.Fprintf(writer, "Enabled user %q\n", *name) + return nil +} diff --git a/weed/shell/command_s3_user_list.go b/weed/shell/command_s3_user_list.go new file mode 100644 index 000000000..7955b3e1a --- /dev/null +++ b/weed/shell/command_s3_user_list.go @@ -0,0 +1,81 @@ +package shell + +import ( + "context" + "fmt" + "io" + "strings" + "text/tabwriter" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" +) + +func init() { + Commands = append(Commands, &commandS3UserList{}) +} + +type commandS3UserList struct { +} + +func (c *commandS3UserList) Name() string { + return "s3.user.list" +} + +func (c *commandS3UserList) Help() string { + return `list S3 IAM users + + s3.user.list + + Lists all users with their status, attached policies, and credential count. +` +} + +func (c *commandS3UserList) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3UserList) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + return pb.WithGrpcClient(false, 0, func(conn *grpc.ClientConn) error { + client := iam_pb.NewSeaweedIdentityAccessManagementClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := client.GetConfiguration(ctx, &iam_pb.GetConfigurationRequest{}) + if err != nil { + return err + } + + identities := resp.Configuration.GetIdentities() + if len(identities) == 0 { + fmt.Fprintln(writer, "No users found.") + return nil + } + + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tSTATUS\tPOLICIES\tKEYS") + + for _, id := range identities { + status := "enabled" + if id.Disabled { + status = "disabled" + } + policies := "-" + if len(id.PolicyNames) > 0 { + policies = joinMax(id.PolicyNames, 3) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%d\n", id.Name, status, policies, len(id.Credentials)) + } + return tw.Flush() + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +} + +// joinMax joins up to max strings with ", " and appends "..." if truncated. +func joinMax(items []string, max int) string { + if len(items) <= max { + return strings.Join(items, ", ") + } + return strings.Join(items[:max], ", ") + "..." +} diff --git a/weed/shell/command_s3_user_show.go b/weed/shell/command_s3_user_show.go new file mode 100644 index 000000000..1841ab1b9 --- /dev/null +++ b/weed/shell/command_s3_user_show.go @@ -0,0 +1,119 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" +) + +func init() { + Commands = append(Commands, &commandS3UserShow{}) +} + +type commandS3UserShow struct { +} + +func (c *commandS3UserShow) Name() string { + return "s3.user.show" +} + +func (c *commandS3UserShow) Help() string { + return `show details of an S3 IAM user + + s3.user.show -name +` +} + +func (c *commandS3UserShow) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3UserShow) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + name := f.String("name", "", "user name") + if err := f.Parse(args); err != nil { + return err + } + + if *name == "" { + return fmt.Errorf("-name is required") + } + + return pb.WithGrpcClient(false, 0, func(conn *grpc.ClientConn) error { + client := iam_pb.NewSeaweedIdentityAccessManagementClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := client.GetUser(ctx, &iam_pb.GetUserRequest{Username: *name}) + if err != nil { + return err + } + id := resp.Identity + if id == nil { + return fmt.Errorf("user %q returned empty identity", *name) + } + + status := "enabled" + if id.Disabled { + status = "disabled" + } + source := "dynamic" + if id.IsStatic { + source = "static" + } + + fmt.Fprintf(writer, "Name: %s\n", id.Name) + fmt.Fprintf(writer, "Status: %s\n", status) + fmt.Fprintf(writer, "Source: %s\n", source) + + if id.Account != nil { + if id.Account.Id != "" { + fmt.Fprintf(writer, "Account: %s", id.Account.Id) + if id.Account.DisplayName != "" { + fmt.Fprintf(writer, " (%s)", id.Account.DisplayName) + } + fmt.Fprintln(writer) + } + if id.Account.EmailAddress != "" { + fmt.Fprintf(writer, "Email: %s\n", id.Account.EmailAddress) + } + } + + if len(id.PolicyNames) > 0 { + fmt.Fprintf(writer, "Policies: %s\n", strings.Join(id.PolicyNames, ", ")) + } else { + fmt.Fprintln(writer, "Policies: (none)") + } + + if len(id.Actions) > 0 { + fmt.Fprintf(writer, "Actions: %s\n", strings.Join(id.Actions, ", ")) + } + + fmt.Fprintln(writer) + if len(id.Credentials) > 0 { + fmt.Fprintln(writer, "Credentials:") + for _, cred := range id.Credentials { + st := cred.Status + if st == "" { + st = "Active" + } + fmt.Fprintf(writer, " %s %s\n", cred.AccessKey, st) + } + } else { + fmt.Fprintln(writer, "Credentials: (none)") + } + + if len(id.ServiceAccountIds) > 0 { + fmt.Fprintf(writer, "\nService Accounts: %s\n", strings.Join(id.ServiceAccountIds, ", ")) + } + + return nil + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +}