From d50889002ba7d86b6506d4df9553da6e8733e68a Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 7 Apr 2026 14:10:15 -0700 Subject: [PATCH] shell: add s3.iam.*, s3.config.show, s3.user.provision; hide legacy commands (#8956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * shell: add s3.iam.*, s3.config.show, s3.user.provision; hide legacy commands Add import/export, configuration summary, and a convenience provisioning command: - s3.iam.export: dump full IAM state as JSON (stdout or file) - s3.iam.import: replace IAM state from a JSON file - s3.config.show: human-readable summary (users, policies, service accounts, groups with status and counts) - s3.user.provision: one-step user+policy+credentials creation for common readonly/readwrite/admin roles Hide legacy commands from help listing: - s3.configure: still works but hidden from help output - s3.bucket.access: still works but hidden from help output Both hidden commands remain fully functional for existing scripts. Also adds a Hidden command tag and filters it from printGenericHelp. * shell: address review feedback for s3.iam.*, s3.config.show, s3.user.provision - Simplify joinMax using strings.Join - Fix rolePolicies: remove s3:ListBucket from object-level actions (already covered by bucket-level statement) - Fix admin role: grant s3:* on bucket resource too - Return flag parse errors instead of swallowing them * shell: address missed review feedback for PR 3 - s3.iam.import: require -force flag for destructive IAM overwrite - s3.config.show: add nil guard for resp.Configuration - s3.user.provision: check if user exists before creating policy - s3.user.provision: reject wildcard bucket names (* ?) * shell: distinguish NotFound from transient errors in provision, use %w wrapping - s3.user.provision: check gRPC status code on GetUser error — only proceed on NotFound, abort on transient/network errors - s3.iam.import: use %w for error wrapping to preserve error chains, wrap PutConfiguration error with context * shell: remove duplicate joinMax after PR 8954 merge command_s3_helpers.go defined joinMax which is already in command_s3_user_list.go from the merged PR 8954. * shell: restrict export file permissions, rollback policy on user create failure - s3.iam.export: use os.OpenFile with mode 0600 instead of os.Create to protect exported credentials from other users - s3.user.provision: rollback the created policy if CreateUser fails, with a warning if the rollback itself fails --- weed/shell/command.go | 1 + weed/shell/command_s3_bucket_access.go | 4 +- weed/shell/command_s3_config_show.go | 116 +++++++++++++++ weed/shell/command_s3_configure.go | 4 +- weed/shell/command_s3_iam_export.go | 80 +++++++++++ weed/shell/command_s3_iam_import.go | 88 ++++++++++++ weed/shell/command_s3_user_provision.go | 179 ++++++++++++++++++++++++ weed/shell/shell_liner.go | 3 + 8 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 weed/shell/command_s3_config_show.go create mode 100644 weed/shell/command_s3_iam_export.go create mode 100644 weed/shell/command_s3_iam_import.go create mode 100644 weed/shell/command_s3_user_provision.go diff --git a/weed/shell/command.go b/weed/shell/command.go index cfd994f3f..14d963b5d 100644 --- a/weed/shell/command.go +++ b/weed/shell/command.go @@ -17,4 +17,5 @@ type CommandTag string const ( ResourceHeavy CommandTag = "resourceHeavy" + Hidden CommandTag = "hidden" ) diff --git a/weed/shell/command_s3_bucket_access.go b/weed/shell/command_s3_bucket_access.go index 48a230ffb..5cae7e268 100644 --- a/weed/shell/command_s3_bucket_access.go +++ b/weed/shell/command_s3_bucket_access.go @@ -61,8 +61,8 @@ func (c *commandS3BucketAccess) Help() string { ` } -func (c *commandS3BucketAccess) HasTag(CommandTag) bool { - return false +func (c *commandS3BucketAccess) HasTag(tag CommandTag) bool { + return tag == Hidden } func (c *commandS3BucketAccess) Do(args []string, commandEnv *CommandEnv, writer io.Writer) (err error) { diff --git a/weed/shell/command_s3_config_show.go b/weed/shell/command_s3_config_show.go new file mode 100644 index 000000000..b0b3506ca --- /dev/null +++ b/weed/shell/command_s3_config_show.go @@ -0,0 +1,116 @@ +package shell + +import ( + "context" + "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, &commandS3ConfigShow{}) +} + +type commandS3ConfigShow struct { +} + +func (c *commandS3ConfigShow) Name() string { + return "s3.config.show" +} + +func (c *commandS3ConfigShow) Help() string { + return `show a summary of the current S3 IAM configuration + + s3.config.show + + Displays counts and a brief listing of users, policies, service accounts, + and groups. Use s3.iam.export for the full JSON dump. +` +} + +func (c *commandS3ConfigShow) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3ConfigShow) 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 + } + cfg := resp.Configuration + if cfg == nil { + fmt.Fprintln(writer, "No S3 IAM configuration found.") + return nil + } + + fmt.Fprintf(writer, "S3 IAM Configuration Summary\n") + fmt.Fprintf(writer, "============================\n\n") + + // Users + fmt.Fprintf(writer, "Users: %d\n", len(cfg.Identities)) + if len(cfg.Identities) > 0 { + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, " NAME\tSTATUS\tSOURCE\tKEYS\tPOLICIES") + for _, id := range cfg.Identities { + status := "enabled" + if id.Disabled { + status = "disabled" + } + source := "dynamic" + if id.IsStatic { + source = "static" + } + policies := "-" + if len(id.PolicyNames) > 0 { + policies = joinMax(id.PolicyNames, 3) + } + fmt.Fprintf(tw, " %s\t%s\t%s\t%d\t%s\n", + id.Name, status, source, len(id.Credentials), policies) + } + tw.Flush() + } + fmt.Fprintln(writer) + + // Policies + fmt.Fprintf(writer, "Policies: %d\n", len(cfg.Policies)) + if len(cfg.Policies) > 0 { + for _, p := range cfg.Policies { + fmt.Fprintf(writer, " %s\n", p.Name) + } + } + fmt.Fprintln(writer) + + // Service Accounts + fmt.Fprintf(writer, "Service Accounts: %d\n", len(cfg.ServiceAccounts)) + if len(cfg.ServiceAccounts) > 0 { + for _, sa := range cfg.ServiceAccounts { + status := "enabled" + if sa.Disabled { + status = "disabled" + } + fmt.Fprintf(writer, " %s (parent: %s, %s)\n", sa.Id, sa.ParentUser, status) + } + } + fmt.Fprintln(writer) + + // Groups + fmt.Fprintf(writer, "Groups: %d\n", len(cfg.Groups)) + if len(cfg.Groups) > 0 { + for _, g := range cfg.Groups { + fmt.Fprintf(writer, " %s (%d members)\n", g.Name, len(g.Members)) + } + } + + return nil + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +} diff --git a/weed/shell/command_s3_configure.go b/weed/shell/command_s3_configure.go index c650e910d..bb1cdcb19 100644 --- a/weed/shell/command_s3_configure.go +++ b/weed/shell/command_s3_configure.go @@ -39,8 +39,8 @@ func (c *commandS3Configure) Help() string { ` } -func (c *commandS3Configure) HasTag(CommandTag) bool { - return false +func (c *commandS3Configure) HasTag(tag CommandTag) bool { + return tag == Hidden } func (c *commandS3Configure) Do(args []string, commandEnv *CommandEnv, writer io.Writer) (err error) { diff --git a/weed/shell/command_s3_iam_export.go b/weed/shell/command_s3_iam_export.go new file mode 100644 index 000000000..b0a1c6d7f --- /dev/null +++ b/weed/shell/command_s3_iam_export.go @@ -0,0 +1,80 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "time" + + "github.com/seaweedfs/seaweedfs/weed/filer" + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" +) + +func init() { + Commands = append(Commands, &commandS3IAMExport{}) +} + +type commandS3IAMExport struct { +} + +func (c *commandS3IAMExport) Name() string { + return "s3.iam.export" +} + +func (c *commandS3IAMExport) Help() string { + return `export the full S3 IAM configuration as JSON + + s3.iam.export + s3.iam.export -file backup.json + + Exports all users, credentials, policies, service accounts, and groups. + Without -file, prints to stdout. +` +} + +func (c *commandS3IAMExport) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3IAMExport) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + file := f.String("file", "", "output file path (stdout if omitted)") + 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.GetConfiguration(ctx, &iam_pb.GetConfigurationRequest{}) + if err != nil { + return err + } + + var out io.Writer = writer + if *file != "" { + fp, err := os.OpenFile(*file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("create file: %v", err) + } + defer fp.Close() + out = fp + } + + if err := filer.ProtoToText(out, resp.Configuration); err != nil { + return err + } + fmt.Fprintln(out) + + if *file != "" { + fmt.Fprintf(writer, "Exported IAM configuration to %s\n", *file) + } + return nil + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) +} diff --git a/weed/shell/command_s3_iam_import.go b/weed/shell/command_s3_iam_import.go new file mode 100644 index 000000000..1b47f528e --- /dev/null +++ b/weed/shell/command_s3_iam_import.go @@ -0,0 +1,88 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "time" + + "github.com/seaweedfs/seaweedfs/weed/filer" + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "google.golang.org/grpc" +) + +func init() { + Commands = append(Commands, &commandS3IAMImport{}) +} + +type commandS3IAMImport struct { +} + +func (c *commandS3IAMImport) Name() string { + return "s3.iam.import" +} + +func (c *commandS3IAMImport) Help() string { + return `import S3 IAM configuration from a JSON file + + s3.iam.import -file backup.json -force + + Replaces the entire IAM configuration (users, credentials, policies, + service accounts, groups) with the contents of the file. + + Requires -force to confirm, since this overwrites the current configuration. +` +} + +func (c *commandS3IAMImport) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3IAMImport) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + file := f.String("file", "", "input JSON file") + force := f.Bool("force", false, "confirm overwrite of the entire IAM configuration") + if err := f.Parse(args); err != nil { + return err + } + + if *file == "" { + return fmt.Errorf("-file is required") + } + if !*force { + return fmt.Errorf("this overwrites the entire IAM configuration; use -force to confirm") + } + + data, err := os.ReadFile(*file) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + + config := &iam_pb.S3ApiConfiguration{} + if err := filer.ParseS3ConfigurationFromBytes(data, config); err != nil { + return fmt.Errorf("parse configuration: %w", 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() + _, err := client.PutConfiguration(ctx, &iam_pb.PutConfigurationRequest{ + Configuration: config, + }) + return err + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + if err != nil { + return fmt.Errorf("put IAM configuration: %w", err) + } + + fmt.Fprintf(writer, "Imported IAM configuration from %s\n", *file) + fmt.Fprintf(writer, " Users: %d\n", len(config.Identities)) + fmt.Fprintf(writer, " Policies: %d\n", len(config.Policies)) + fmt.Fprintf(writer, " Service Accounts: %d\n", len(config.ServiceAccounts)) + fmt.Fprintf(writer, " Groups: %d\n", len(config.Groups)) + return nil +} diff --git a/weed/shell/command_s3_user_provision.go b/weed/shell/command_s3_user_provision.go new file mode 100644 index 000000000..040fafa3d --- /dev/null +++ b/weed/shell/command_s3_user_provision.go @@ -0,0 +1,179 @@ +package shell + +import ( + "context" + "encoding/json" + "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" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func init() { + Commands = append(Commands, &commandS3UserProvision{}) +} + +type commandS3UserProvision struct { +} + +func (c *commandS3UserProvision) Name() string { + return "s3.user.provision" +} + +func (c *commandS3UserProvision) Help() string { + return `create a user with a bucket policy in one step + + s3.user.provision -name -bucket -role readwrite + s3.user.provision -name -bucket -role readonly + + Convenience wrapper that performs these steps: + 1. Creates an IAM policy for the bucket and role + 2. Creates the user with auto-generated credentials + 3. Attaches the policy to the user + + Roles: + readonly - s3:GetObject, s3:ListBucket + readwrite - s3:GetObject, s3:PutObject, s3:DeleteObject, s3:ListBucket + admin - s3:* (full access to the bucket) +` +} + +func (c *commandS3UserProvision) HasTag(CommandTag) bool { + return false +} + +var rolePolicies = map[string][]string{ + "readonly": {"s3:GetObject"}, + "readwrite": {"s3:GetObject", "s3:PutObject", "s3:DeleteObject"}, + "admin": {"s3:*"}, +} + +func (c *commandS3UserProvision) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error { + f := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + name := f.String("name", "", "user name") + bucket := f.String("bucket", "", "bucket name") + role := f.String("role", "", "role: readonly, readwrite, or admin") + if err := f.Parse(args); err != nil { + return err + } + + if *name == "" { + return fmt.Errorf("-name is required") + } + if *bucket == "" { + return fmt.Errorf("-bucket is required") + } + if strings.ContainsAny(*bucket, "*?") { + return fmt.Errorf("-bucket must be a literal bucket name, not a wildcard pattern") + } + if *role == "" { + return fmt.Errorf("-role is required (readonly, readwrite, admin)") + } + + actions, ok := rolePolicies[*role] + if !ok { + return fmt.Errorf("unknown role %q: must be readonly, readwrite, or admin", *role) + } + + policyName := fmt.Sprintf("%s-%s-%s", *bucket, *name, *role) + + // Build the policy document + bucketActions := []string{"s3:ListBucket"} + if *role == "admin" { + bucketActions = []string{"s3:*"} + } + policyDoc := map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{ + { + "Effect": "Allow", + "Action": actions, + "Resource": []string{fmt.Sprintf("arn:aws:s3:::%s/*", *bucket)}, + }, + { + "Effect": "Allow", + "Action": bucketActions, + "Resource": []string{fmt.Sprintf("arn:aws:s3:::%s", *bucket)}, + }, + }, + } + policyJSON, err := json.Marshal(policyDoc) + if err != nil { + return fmt.Errorf("marshal policy: %v", err) + } + + // Generate credentials + 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) + } + + 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() + + // Step 0: Check if user already exists + if resp, getErr := client.GetUser(ctx, &iam_pb.GetUserRequest{Username: *name}); getErr == nil && resp.Identity != nil { + return fmt.Errorf("user %q already exists", *name) + } else if getErr != nil && status.Code(getErr) != codes.NotFound { + return fmt.Errorf("check user existence: %w", getErr) + } + + // Step 1: Create policy + _, err := client.PutPolicy(ctx, &iam_pb.PutPolicyRequest{ + Name: policyName, + Content: string(policyJSON), + }) + if err != nil { + return fmt.Errorf("create policy: %v", err) + } + fmt.Fprintf(writer, "Created policy %q\n", policyName) + + // Step 2: Create user + identity := &iam_pb.Identity{ + Name: *name, + Credentials: []*iam_pb.Credential{ + { + AccessKey: ak, + SecretKey: sk, + Status: iam.AccessKeyStatusActive, + }, + }, + PolicyNames: []string{policyName}, + } + _, err = client.CreateUser(ctx, &iam_pb.CreateUserRequest{Identity: identity}) + if err != nil { + // Rollback: remove the policy we just created + if _, delErr := client.DeletePolicy(ctx, &iam_pb.DeletePolicyRequest{Name: policyName}); delErr != nil { + fmt.Fprintf(writer, "Warning: failed to rollback policy %q: %v\n", policyName, delErr) + } + return fmt.Errorf("create user: %w", err) + } + fmt.Fprintf(writer, "Created user %q with policy %q attached\n", *name, policyName) + + return nil + }, commandEnv.option.FilerAddress.ToGrpcAddress(), false, commandEnv.option.GrpcDialOption) + if err != nil { + return err + } + + fmt.Fprintln(writer) + 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/shell_liner.go b/weed/shell/shell_liner.go index 78afc7880..2d756dc63 100644 --- a/weed/shell/shell_liner.go +++ b/weed/shell/shell_liner.go @@ -186,6 +186,9 @@ func printGenericHelp() { fmt.Print(msg) for _, c := range Commands { + if c.HasTag(Hidden) { + continue + } helpTexts := strings.SplitN(c.Help(), "\n", 2) fmt.Printf(" %-30s\t# %s \n", c.Name(), helpTexts[0]) }