diff --git a/weed/shell/command_s3_accesskey_create.go b/weed/shell/command_s3_accesskey_create.go new file mode 100644 index 000000000..0b26f3bcc --- /dev/null +++ b/weed/shell/command_s3_accesskey_create.go @@ -0,0 +1,96 @@ +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, &commandS3AccessKeyCreate{}) +} + +type commandS3AccessKeyCreate struct { +} + +func (c *commandS3AccessKeyCreate) Name() string { + return "s3.accesskey.create" +} + +func (c *commandS3AccessKeyCreate) Help() string { + return `create an additional access key for an S3 IAM user + + s3.accesskey.create -user + s3.accesskey.create -user -access_key -secret_key + + Generates a new credential pair for an existing user. If -access_key and + -secret_key are omitted, they are generated automatically. +` +} + +func (c *commandS3AccessKeyCreate) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3AccessKeyCreate) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + user := f.String("user", "", "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 *user == "" { + return fmt.Errorf("-user 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") + } + + 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.CreateAccessKey(ctx, &iam_pb.CreateAccessKeyRequest{ + Username: *user, + Credential: &iam_pb.Credential{ + AccessKey: ak, + SecretKey: sk, + Status: iam.AccessKeyStatusActive, + }, + }) + return err + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + if err != nil { + return err + } + + fmt.Fprintf(writer, "Created access key for user %q\n", *user) + 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_accesskey_delete.go b/weed/shell/command_s3_accesskey_delete.go new file mode 100644 index 000000000..9d86b0f42 --- /dev/null +++ b/weed/shell/command_s3_accesskey_delete.go @@ -0,0 +1,68 @@ +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, &commandS3AccessKeyDelete{}) +} + +type commandS3AccessKeyDelete struct { +} + +func (c *commandS3AccessKeyDelete) Name() string { + return "s3.accesskey.delete" +} + +func (c *commandS3AccessKeyDelete) Help() string { + return `delete an access key from an S3 IAM user + + s3.accesskey.delete -user -access_key +` +} + +func (c *commandS3AccessKeyDelete) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3AccessKeyDelete) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + user := f.String("user", "", "user name") + accessKey := f.String("access_key", "", "access key to delete") + if err := f.Parse(args); err != nil { + return err + } + + if *user == "" { + return fmt.Errorf("-user is required") + } + if *accessKey == "" { + return fmt.Errorf("-access_key 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.DeleteAccessKey(ctx, &iam_pb.DeleteAccessKeyRequest{ + Username: *user, + AccessKey: *accessKey, + }) + return err + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + if err != nil { + return err + } + + fmt.Fprintf(writer, "Deleted access key %s from user %q\n", *accessKey, *user) + return nil +} diff --git a/weed/shell/command_s3_accesskey_list.go b/weed/shell/command_s3_accesskey_list.go new file mode 100644 index 000000000..4ab752336 --- /dev/null +++ b/weed/shell/command_s3_accesskey_list.go @@ -0,0 +1,75 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "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, &commandS3AccessKeyList{}) +} + +type commandS3AccessKeyList struct { +} + +func (c *commandS3AccessKeyList) Name() string { + return "s3.accesskey.list" +} + +func (c *commandS3AccessKeyList) Help() string { + return `list access keys for an S3 IAM user + + s3.accesskey.list -user +` +} + +func (c *commandS3AccessKeyList) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3AccessKeyList) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + user := f.String("user", "", "user name") + if err := f.Parse(args); err != nil { + return err + } + + 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 err + } + + if len(resp.Identity.Credentials) == 0 { + fmt.Fprintf(writer, "No access keys for user %q.\n", *user) + return nil + } + + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "ACCESS KEY\tSTATUS") + for _, cred := range resp.Identity.Credentials { + st := cred.Status + if st == "" { + st = "Active" + } + fmt.Fprintf(tw, "%s\t%s\n", cred.AccessKey, st) + } + return tw.Flush() + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +} diff --git a/weed/shell/command_s3_accesskey_rotate.go b/weed/shell/command_s3_accesskey_rotate.go new file mode 100644 index 000000000..bd9fce68c --- /dev/null +++ b/weed/shell/command_s3_accesskey_rotate.go @@ -0,0 +1,105 @@ +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, &commandS3AccessKeyRotate{}) +} + +type commandS3AccessKeyRotate struct { +} + +func (c *commandS3AccessKeyRotate) Name() string { + return "s3.accesskey.rotate" +} + +func (c *commandS3AccessKeyRotate) Help() string { + return `rotate an access key for an S3 IAM user + + s3.accesskey.rotate -user -access_key + + Creates a new credential pair and deletes the old one. There is a brief + window where both keys are valid. +` +} + +func (c *commandS3AccessKeyRotate) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3AccessKeyRotate) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + user := f.String("user", "", "user name") + oldKey := f.String("access_key", "", "access key to rotate") + if err := f.Parse(args); err != nil { + return err + } + + if *user == "" { + return fmt.Errorf("-user is required") + } + if *oldKey == "" { + return fmt.Errorf("-access_key is required") + } + + newAK, err := iam.GenerateRandomString(iam.AccessKeyIdLength, iam.CharsetUpper) + if err != nil { + return fmt.Errorf("generate access key: %v", err) + } + newSK, err := iam.GenerateSecretAccessKey() + if err != nil { + return fmt.Errorf("generate secret key: %v", err) + } + + 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() + + // Create new key first so there's no gap without credentials + _, err := client.CreateAccessKey(ctx, &iam_pb.CreateAccessKeyRequest{ + Username: *user, + Credential: &iam_pb.Credential{ + AccessKey: newAK, + SecretKey: newSK, + Status: iam.AccessKeyStatusActive, + }, + }) + if err != nil { + return fmt.Errorf("create new key: %v", err) + } + + // Delete old key + _, err = client.DeleteAccessKey(ctx, &iam_pb.DeleteAccessKeyRequest{ + Username: *user, + AccessKey: *oldKey, + }) + if err != nil { + return fmt.Errorf("delete old key (new key %s was already created): %v", newAK, err) + } + + return nil + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + if err != nil { + return err + } + + fmt.Fprintf(writer, "Rotated access key for user %q\n", *user) + fmt.Fprintf(writer, "Old Key: %s (deleted)\n", *oldKey) + fmt.Fprintf(writer, "Access Key: %s\n", newAK) + fmt.Fprintf(writer, "Secret Key: %s\n", newSK) + 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_anonymous_get.go b/weed/shell/command_s3_anonymous_get.go new file mode 100644 index 000000000..92580a0c6 --- /dev/null +++ b/weed/shell/command_s3_anonymous_get.go @@ -0,0 +1,89 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func init() { + Commands = append(Commands, &commandS3AnonymousGet{}) +} + +type commandS3AnonymousGet struct { +} + +func (c *commandS3AnonymousGet) Name() string { + return "s3.anonymous.get" +} + +func (c *commandS3AnonymousGet) Help() string { + return `show anonymous access for a bucket + + s3.anonymous.get -bucket +` +} + +func (c *commandS3AnonymousGet) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3AnonymousGet) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + bucket := f.String("bucket", "", "bucket name") + if err := f.Parse(args); err != nil { + return err + } + + if *bucket == "" { + return fmt.Errorf("-bucket 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: anonymousUserName}) + if err != nil { + st, ok := status.FromError(err) + if ok && st.Code() == codes.NotFound { + fmt.Fprintf(writer, "Bucket: %s\nAccess: none\n", *bucket) + return nil + } + return err + } + if resp.Identity == nil { + fmt.Fprintf(writer, "Bucket: %s\nAccess: none\n", *bucket) + return nil + } + + var actions []string + for _, a := range resp.Identity.Actions { + parts := strings.SplitN(a, ":", 2) + if len(parts) == 2 && parts[1] == *bucket { + actions = append(actions, parts[0]) + } + } + + fmt.Fprintf(writer, "Bucket: %s\n", *bucket) + if len(actions) == 0 { + fmt.Fprintln(writer, "Access: none") + } else { + sort.Strings(actions) + fmt.Fprintf(writer, "Access: %s\n", strings.Join(actions, ", ")) + } + + return nil + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +} diff --git a/weed/shell/command_s3_anonymous_list.go b/weed/shell/command_s3_anonymous_list.go new file mode 100644 index 000000000..896408717 --- /dev/null +++ b/weed/shell/command_s3_anonymous_list.go @@ -0,0 +1,91 @@ +package shell + +import ( + "context" + "fmt" + "io" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func init() { + Commands = append(Commands, &commandS3AnonymousList{}) +} + +type commandS3AnonymousList struct { +} + +func (c *commandS3AnonymousList) Name() string { + return "s3.anonymous.list" +} + +func (c *commandS3AnonymousList) Help() string { + return `list all buckets with anonymous access + + s3.anonymous.list +` +} + +func (c *commandS3AnonymousList) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3AnonymousList) 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.GetUser(ctx, &iam_pb.GetUserRequest{Username: anonymousUserName}) + if err != nil { + st, ok := status.FromError(err) + if ok && st.Code() == codes.NotFound { + fmt.Fprintln(writer, "No anonymous access configured.") + return nil + } + return err + } + if resp.Identity == nil { + fmt.Fprintln(writer, "No anonymous access configured.") + return nil + } + + // Group actions by bucket + bucketActions := map[string][]string{} + for _, a := range resp.Identity.Actions { + parts := strings.SplitN(a, ":", 2) + if len(parts) == 2 { + bucketActions[parts[1]] = append(bucketActions[parts[1]], parts[0]) + } + } + + if len(bucketActions) == 0 { + fmt.Fprintln(writer, "No anonymous access configured.") + return nil + } + + // Sort bucket names + buckets := make([]string, 0, len(bucketActions)) + for b := range bucketActions { + buckets = append(buckets, b) + } + sort.Strings(buckets) + + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "BUCKET\tACCESS") + for _, b := range buckets { + actions := bucketActions[b] + sort.Strings(actions) + fmt.Fprintf(tw, "%s\t%s\n", b, strings.Join(actions, ", ")) + } + return tw.Flush() + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +} diff --git a/weed/shell/command_s3_anonymous_set.go b/weed/shell/command_s3_anonymous_set.go new file mode 100644 index 000000000..e76a8d77b --- /dev/null +++ b/weed/shell/command_s3_anonymous_set.go @@ -0,0 +1,144 @@ +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" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const anonymousUserName = "anonymous" + +func init() { + Commands = append(Commands, &commandS3AnonymousSet{}) +} + +type commandS3AnonymousSet struct { +} + +func (c *commandS3AnonymousSet) Name() string { + return "s3.anonymous.set" +} + +func (c *commandS3AnonymousSet) Help() string { + return `set anonymous (public) access on a bucket + + s3.anonymous.set -bucket -access Read,List + s3.anonymous.set -bucket -access none + + Supported actions: Read, Write, List, Tagging, Admin + Use "none" to remove all anonymous access for the bucket. + + This manages the special "anonymous" user's actions. It does not + use IAM policies — it sets legacy per-bucket actions directly. +` +} + +func (c *commandS3AnonymousSet) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3AnonymousSet) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + bucket := f.String("bucket", "", "bucket name") + access := f.String("access", "", "comma-separated actions: Read,Write,List,Tagging,Admin or none") + if err := f.Parse(args); err != nil { + return err + } + + if *bucket == "" { + return fmt.Errorf("-bucket is required") + } + if *access == "" { + return fmt.Errorf("-access 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() + + // Get or create anonymous user + identity, isNew, err := getOrCreateAnonymousUser(ctx, client) + if err != nil { + return err + } + + // Remove existing actions for this bucket + var kept []string + for _, a := range identity.Actions { + parts := strings.SplitN(a, ":", 2) + if len(parts) != 2 || parts[1] != *bucket { + kept = append(kept, a) + } + } + + // Add new actions unless "none" + canonicalActions := map[string]string{ + "read": "Read", "write": "Write", "list": "List", + "tagging": "Tagging", "admin": "Admin", + } + if strings.ToLower(strings.TrimSpace(*access)) != "none" { + seen := make(map[string]struct{}) + for _, action := range strings.Split(*access, ",") { + action = strings.TrimSpace(action) + if action != "" { + canonical, ok := canonicalActions[strings.ToLower(action)] + if !ok { + return fmt.Errorf("invalid action %q: supported actions are Read, Write, List, Tagging, Admin", action) + } + if _, dup := seen[canonical]; dup { + continue + } + seen[canonical] = struct{}{} + kept = append(kept, canonical+":"+*bucket) + } + } + } + + identity.Actions = kept + + if isNew { + _, err = client.CreateUser(ctx, &iam_pb.CreateUserRequest{Identity: identity}) + } else { + _, err = client.UpdateUser(ctx, &iam_pb.UpdateUserRequest{ + Username: anonymousUserName, + Identity: identity, + }) + } + if err != nil { + return err + } + + fmt.Fprintf(writer, "Set anonymous access on bucket %q to: %s\n", *bucket, *access) + return nil + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +} + +func getOrCreateAnonymousUser(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) (*iam_pb.Identity, bool, error) { + resp, err := client.GetUser(ctx, &iam_pb.GetUserRequest{Username: anonymousUserName}) + if err == nil { + if resp.Identity == nil { + return nil, false, fmt.Errorf("anonymous user returned nil identity") + } + return resp.Identity, false, nil + } + + st, ok := status.FromError(err) + if ok && st != nil && st.Code() == codes.NotFound { + return &iam_pb.Identity{ + Name: anonymousUserName, + Actions: []string{}, + }, true, nil + } + + return nil, false, fmt.Errorf("failed to get anonymous user: %w", err) +} diff --git a/weed/shell/command_s3_serviceaccount_create.go b/weed/shell/command_s3_serviceaccount_create.go new file mode 100644 index 000000000..29dd8c3ad --- /dev/null +++ b/weed/shell/command_s3_serviceaccount_create.go @@ -0,0 +1,132 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + "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, &commandS3ServiceAccountCreate{}) +} + +type commandS3ServiceAccountCreate struct { +} + +func (c *commandS3ServiceAccountCreate) Name() string { + return "s3.serviceaccount.create" +} + +func (c *commandS3ServiceAccountCreate) Help() string { + return `create a service account for an S3 IAM user + + s3.serviceaccount.create -user -description "my app" + s3.serviceaccount.create -user -actions Read,List -expiry 24h + + Service accounts are linked to a parent user and can have restricted + permissions (a subset of the parent's actions). +` +} + +func (c *commandS3ServiceAccountCreate) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3ServiceAccountCreate) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + user := f.String("user", "", "parent user name") + description := f.String("description", "", "optional description") + actions := f.String("actions", "", "comma-separated actions (subset of parent)") + expiry := f.Duration("expiry", 0, "expiration duration (e.g. 24h, 0 = no expiration)") + if err := f.Parse(args); err != nil { + return err + } + + if *user == "" { + return fmt.Errorf("-user is required") + } + + 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) + } + + sa := &iam_pb.ServiceAccount{ + ParentUser: *user, + Description: *description, + Credential: &iam_pb.Credential{ + AccessKey: ak, + SecretKey: sk, + Status: iam.AccessKeyStatusActive, + }, + CreatedAt: time.Now().Unix(), + } + + validActions := map[string]string{ + "read": "Read", "write": "Write", "list": "List", + "tagging": "Tagging", "admin": "Admin", + } + if *actions != "" { + seen := make(map[string]struct{}) + for _, a := range strings.Split(*actions, ",") { + a = strings.TrimSpace(a) + if a != "" { + canonical, ok := validActions[strings.ToLower(a)] + if !ok { + return fmt.Errorf("invalid action %q: supported actions are Read, Write, List, Tagging, Admin", a) + } + if _, dup := seen[canonical]; dup { + continue + } + seen[canonical] = struct{}{} + sa.Actions = append(sa.Actions, canonical) + } + } + } + + if *expiry < 0 { + return fmt.Errorf("-expiry must be >= 0") + } + if *expiry > 0 { + sa.Expiration = time.Now().Add(*expiry).Unix() + } + + 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.CreateServiceAccount(ctx, &iam_pb.CreateServiceAccountRequest{ + ServiceAccount: sa, + }) + return err + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + if err != nil { + return err + } + + fmt.Fprintf(writer, "Created service account for user %q\n", *user) + fmt.Fprintln(writer, "Note: use s3.serviceaccount.list to find the server-assigned ID.") + fmt.Fprintf(writer, "Access Key: %s\n", ak) + fmt.Fprintf(writer, "Secret Key: %s\n", sk) + if *description != "" { + fmt.Fprintf(writer, "Desc: %s\n", *description) + } + if *expiry > 0 { + fmt.Fprintf(writer, "Expires: %s\n", time.Unix(sa.Expiration, 0).Format(time.RFC3339)) + } + 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_serviceaccount_delete.go b/weed/shell/command_s3_serviceaccount_delete.go new file mode 100644 index 000000000..b56b9176e --- /dev/null +++ b/weed/shell/command_s3_serviceaccount_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, &commandS3ServiceAccountDelete{}) +} + +type commandS3ServiceAccountDelete struct { +} + +func (c *commandS3ServiceAccountDelete) Name() string { + return "s3.serviceaccount.delete" +} + +func (c *commandS3ServiceAccountDelete) Help() string { + return `delete a service account + + s3.serviceaccount.delete -id +` +} + +func (c *commandS3ServiceAccountDelete) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3ServiceAccountDelete) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + id := f.String("id", "", "service account ID") + if err := f.Parse(args); err != nil { + return err + } + + if *id == "" { + return fmt.Errorf("-id 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.DeleteServiceAccount(ctx, &iam_pb.DeleteServiceAccountRequest{Id: *id}) + return err + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + if err != nil { + return err + } + + fmt.Fprintf(writer, "Deleted service account %q\n", *id) + return nil +} diff --git a/weed/shell/command_s3_serviceaccount_list.go b/weed/shell/command_s3_serviceaccount_list.go new file mode 100644 index 000000000..ba41d5421 --- /dev/null +++ b/weed/shell/command_s3_serviceaccount_list.go @@ -0,0 +1,85 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "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, &commandS3ServiceAccountList{}) +} + +type commandS3ServiceAccountList struct { +} + +func (c *commandS3ServiceAccountList) Name() string { + return "s3.serviceaccount.list" +} + +func (c *commandS3ServiceAccountList) Help() string { + return `list service accounts + + s3.serviceaccount.list + s3.serviceaccount.list -user + + Lists all service accounts, optionally filtered by parent user. +` +} + +func (c *commandS3ServiceAccountList) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3ServiceAccountList) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + user := f.String("user", "", "filter by parent user (optional)") + if err := f.Parse(args); err != nil { + return err + } + + 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.ListServiceAccounts(ctx, &iam_pb.ListServiceAccountsRequest{}) + if err != nil { + return err + } + + var filtered []*iam_pb.ServiceAccount + for _, sa := range resp.ServiceAccounts { + if *user == "" || sa.ParentUser == *user { + filtered = append(filtered, sa) + } + } + + if len(filtered) == 0 { + fmt.Fprintln(writer, "No service accounts found.") + return nil + } + + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tPARENT\tSTATUS\tDESCRIPTION") + for _, sa := range filtered { + st := "enabled" + if sa.Disabled { + st = "disabled" + } + desc := sa.Description + if len(desc) > 40 { + desc = desc[:37] + "..." + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", sa.Id, sa.ParentUser, st, desc) + } + return tw.Flush() + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +} diff --git a/weed/shell/command_s3_serviceaccount_show.go b/weed/shell/command_s3_serviceaccount_show.go new file mode 100644 index 000000000..fc7da181e --- /dev/null +++ b/weed/shell/command_s3_serviceaccount_show.go @@ -0,0 +1,93 @@ +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, &commandS3ServiceAccountShow{}) +} + +type commandS3ServiceAccountShow struct { +} + +func (c *commandS3ServiceAccountShow) Name() string { + return "s3.serviceaccount.show" +} + +func (c *commandS3ServiceAccountShow) Help() string { + return `show details of a service account + + s3.serviceaccount.show -id +` +} + +func (c *commandS3ServiceAccountShow) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3ServiceAccountShow) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + id := f.String("id", "", "service account ID") + if err := f.Parse(args); err != nil { + return err + } + + if *id == "" { + return fmt.Errorf("-id 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.GetServiceAccount(ctx, &iam_pb.GetServiceAccountRequest{Id: *id}) + if err != nil { + return err + } + sa := resp.ServiceAccount + + status := "enabled" + if sa.Disabled { + status = "disabled" + } + + fmt.Fprintf(writer, "ID: %s\n", sa.Id) + fmt.Fprintf(writer, "Parent: %s\n", sa.ParentUser) + fmt.Fprintf(writer, "Status: %s\n", status) + if sa.Description != "" { + fmt.Fprintf(writer, "Description: %s\n", sa.Description) + } + if sa.Credential != nil { + st := sa.Credential.Status + if st == "" { + st = "Active" + } + fmt.Fprintf(writer, "Access Key: %s (%s)\n", sa.Credential.AccessKey, st) + } + if len(sa.Actions) > 0 { + fmt.Fprintf(writer, "Actions: %s\n", strings.Join(sa.Actions, ", ")) + } + if sa.Expiration > 0 { + fmt.Fprintf(writer, "Expires: %s\n", time.Unix(sa.Expiration, 0).Format(time.RFC3339)) + } + if sa.CreatedAt > 0 { + fmt.Fprintf(writer, "Created: %s\n", time.Unix(sa.CreatedAt, 0).Format(time.RFC3339)) + } + if sa.CreatedBy != "" { + fmt.Fprintf(writer, "Created By: %s\n", sa.CreatedBy) + } + + return nil + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +}