mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-05-13 21:31:32 +00:00
* fix(shell): s3.user.provision handles existing users by attaching policy Instead of erroring when the user already exists, the command now creates the policy and attaches it to the existing user via UpdateUser. Credentials are only generated and displayed for newly created users. * fix(shell): skip duplicate policy attachment in s3.user.provision Check if the policy is already attached before appending and calling UpdateUser, making repeated runs idempotent. * fix(shell): generate service account ID in s3.serviceaccount.create The command built a ServiceAccount proto without setting Id, which was rejected by credential.ValidateServiceAccountId on any real store. Now generates sa:<parent>:<uuid> matching the format used by the admin UI. * test(s3): integration tests for s3.* shell commands Adds TestShell* integration tests covering ~40 previously untested shell commands: user, accesskey, group, serviceaccount, anonymous, bucket, policy.attach/detach, config.show, and iam.export/import. Switches the test cluster's credential store from memory to filer_etc because the memory store silently drops groups and service accounts in LoadConfiguration/SaveConfiguration. * fix(shell): rollback policy on key generation failure in s3.user.provision If iam.GenerateRandomString or iam.GenerateSecretAccessKey fails after the policy was persisted, the policy would be left orphaned. Extracts the rollback logic into a local closure and invokes it on all failure paths after policy creation for consistency. * address PR review feedback for s3 shell tests and serviceaccount - s3.serviceaccount.create: use 16 bytes of randomness (hex-encoded) for the service account UUID instead of 4 bytes to eliminate collision risk - s3.serviceaccount.create: print the actual ID and drop the outdated "server-assigned" note (the ID is now client-generated) - tests: guard createdAK in accesskey rotate/delete subtests so sibling failures don't run invalid CLI calls - tests: requireContains/requireNotContains use t.Fatalf to fail fast - tests: Provision subtest asserts the "Attached policy" message on the second provision call for an existing user - tests: update extractServiceAccountID comment example to match the sa:<parent>:<uuid> format - tests: drop redundant saID empty-check (extractServiceAccountID fatals) * test(s3): use t.Fatalf for precondition check in serviceaccount test
211 lines
6.1 KiB
Go
211 lines
6.1 KiB
Go
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 <username> -bucket <bucket_name> -role readwrite
|
|
s3.user.provision -name <username> -bucket <bucket_name> -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)
|
|
}
|
|
|
|
var ak, sk string
|
|
var userCreated bool
|
|
|
|
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
|
|
var existingIdentity *iam_pb.Identity
|
|
if resp, getErr := client.GetUser(ctx, &iam_pb.GetUserRequest{Username: *name}); getErr == nil && resp.Identity != nil {
|
|
existingIdentity = resp.Identity
|
|
fmt.Fprintf(writer, "User %q already exists, adding policy\n", *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)
|
|
|
|
// rollbackPolicy removes the policy we just created. Used when a later
|
|
// step fails, to avoid leaving the policy orphaned.
|
|
rollbackPolicy := func() {
|
|
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)
|
|
}
|
|
}
|
|
|
|
if existingIdentity != nil {
|
|
// User exists: attach the new policy if not already present
|
|
for _, pn := range existingIdentity.PolicyNames {
|
|
if pn == policyName {
|
|
fmt.Fprintf(writer, "Policy %q already attached to user %q\n", policyName, *name)
|
|
return nil
|
|
}
|
|
}
|
|
existingIdentity.PolicyNames = append(existingIdentity.PolicyNames, policyName)
|
|
_, err = client.UpdateUser(ctx, &iam_pb.UpdateUserRequest{Username: *name, Identity: existingIdentity})
|
|
if err != nil {
|
|
rollbackPolicy()
|
|
return fmt.Errorf("attach policy to existing user: %w", err)
|
|
}
|
|
fmt.Fprintf(writer, "Attached policy %q to existing user %q\n", policyName, *name)
|
|
} else {
|
|
// Step 2: Create new user with credentials
|
|
ak, err = iam.GenerateRandomString(iam.AccessKeyIdLength, iam.CharsetUpper)
|
|
if err != nil {
|
|
rollbackPolicy()
|
|
return fmt.Errorf("generate access key: %v", err)
|
|
}
|
|
sk, err = iam.GenerateSecretAccessKey()
|
|
if err != nil {
|
|
rollbackPolicy()
|
|
return fmt.Errorf("generate secret key: %v", err)
|
|
}
|
|
|
|
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 {
|
|
rollbackPolicy()
|
|
return fmt.Errorf("create user: %w", err)
|
|
}
|
|
userCreated = true
|
|
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
|
|
}
|
|
|
|
if userCreated {
|
|
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
|
|
}
|