From b9f55722e0f9cba629a0924d7690824d8ccac908 Mon Sep 17 00:00:00 2001 From: mrrishi Date: Wed, 12 Feb 2025 00:55:05 +0530 Subject: [PATCH 1/2] feat: remove assume role --- cmd/aws/command.go | 19 +-- cmd/aws/create.go | 299 +++++++++++++++++++++++++++++++++++++-- cmd/aws/create_test.go | 226 +++++++++++++++++++++++++---- internal/aws/aws.go | 65 +++++++++ internal/aws/aws_test.go | 127 +++++++++++++++++ internal/launch/cmd.go | 2 +- internal/types/flags.go | 1 + 7 files changed, 687 insertions(+), 52 deletions(-) create mode 100644 internal/aws/aws.go create mode 100644 internal/aws/aws_test.go diff --git a/cmd/aws/command.go b/cmd/aws/command.go index 681160eaf..2a073103b 100644 --- a/cmd/aws/command.go +++ b/cmd/aws/command.go @@ -18,7 +18,6 @@ import ( "github.com/konstructio/kubefirst/internal/step" "github.com/konstructio/kubefirst/internal/utilities" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -112,29 +111,13 @@ func Create() *cobra.Command { return wrerr } - err = ValidateProvidedFlags(ctx, cfg, cliFlags.GitProvider, cliFlags.AMIType, cliFlags.NodeType) + err = ValidateProvidedFlags(ctx, cfg, cliFlags) if err != nil { wrerr := fmt.Errorf("failed to validate provided flags: %w", err) stepper.FailCurrentStep(wrerr) return wrerr } - creds, err := getSessionCredentials(ctx, cfg.Credentials) - if err != nil { - wrerr := fmt.Errorf("failed to get session credentials: %w", err) - stepper.FailCurrentStep(wrerr) - return wrerr - } - - viper.Set("kubefirst.state-store-creds.access-key-id", creds.AccessKeyID) - viper.Set("kubefirst.state-store-creds.secret-access-key-id", creds.SecretAccessKey) - viper.Set("kubefirst.state-store-creds.token", creds.SessionToken) - if err := viper.WriteConfig(); err != nil { - wrerr := fmt.Errorf("failed to write config: %w", err) - stepper.FailCurrentStep(wrerr) - return wrerr - } - clusterClient := cluster.Client{} provision := provision.NewProvisioner(provision.NewProvisionWatcher(cliFlags.ClusterName, &clusterClient), stepper) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 696e235a9..3d13cbbab 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -8,27 +8,72 @@ package aws import ( "context" + "encoding/json" + "errors" "fmt" + "net/http" "os" "slices" + "time" "github.com/aws/aws-sdk-go-v2/aws" + awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/sts" + awsTypes "github.com/aws/aws-sdk-go-v2/service/sts/types" internalssh "github.com/konstructio/kubefirst-api/pkg/ssh" + internalaws "github.com/konstructio/kubefirst/internal/aws" + "github.com/konstructio/kubefirst/internal/progress" + "github.com/konstructio/kubefirst/internal/types" + "github.com/spf13/viper" + "github.com/rs/zerolog/log" ) -func ValidateProvidedFlags(ctx context.Context, cfg aws.Config, gitProvider, amiType, nodeType string) error { +const ( + maxRetries = 20 + baseDelay = 2 * time.Second +) + +func ValidateProvidedFlags(ctx context.Context, cfg aws.Config, cliFlags *types.CliFlags) error { // Validate required environment variables for dns provider + + stsClient := sts.NewFromConfig(cfg) + iamClient := iam.NewFromConfig(cfg) + checker, err := internalaws.NewChecker(ctx) + if err != nil { + return fmt.Errorf("failed to perform aws checks: %w", err) + } + + accessKeyID := viper.Get("kubefirst.state-store-creds.access-key-id") + secretAccessKeyID := viper.Get("kubefirst.state-store-creds.secret-access-key-id") + sessionToken := viper.Get("kubefirst.state-store-creds.token") + + if accessKeyID == nil || secretAccessKeyID == nil && sessionToken == nil { + creds, err := convertLocalCredsToSession(ctx, stsClient, iamClient, checker, cliFlags.KubeAdminRoleARN, cliFlags.ClusterName) + if err != nil { + progress.Error(err.Error()) + return fmt.Errorf("failed to retrieve AWS credentials: %w", err) + } + + viper.Set("kubefirst.state-store-creds.access-key-id", creds.AccessKeyId) + viper.Set("kubefirst.state-store-creds.secret-access-key-id", creds.SecretAccessKey) + viper.Set("kubefirst.state-store-creds.token", creds.SessionToken) + if err := viper.WriteConfig(); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + } + if dnsProviderFlag == "cloudflare" { if os.Getenv("CF_API_TOKEN") == "" { return fmt.Errorf("your CF_API_TOKEN environment variable is not set. Please set and try again") } } - switch gitProvider { + switch cliFlags.GitProvider { case "github": key, err := internalssh.GetHostKey("github.com") if err != nil { @@ -47,21 +92,241 @@ func ValidateProvidedFlags(ctx context.Context, cfg aws.Config, gitProvider, ami ec2Client := ec2.NewFromConfig(cfg) paginator := ec2.NewDescribeInstanceTypesPaginator(ec2Client, &ec2.DescribeInstanceTypesInput{}) - if err := validateAMIType(ctx, amiType, nodeType, ssmClient, ec2Client, paginator); err != nil { + if err := validateAMIType(ctx, amiType, cliFlags.NodeType, ssmClient, ec2Client, paginator); err != nil { return fmt.Errorf("failed to validate ami type for node group: %w", err) } return nil } -func getSessionCredentials(ctx context.Context, cp aws.CredentialsProvider) (*aws.Credentials, error) { - // Retrieve credentials - creds, err := cp.Retrieve(ctx) +const ( + sessionDuration = int32(43200) // We want at least 6 hours (21,600 seconds) +) + +var wantedPermissions = []string{ + "eks:*", + "ec2:*", + "s3:*", + "iam:*", + "dynamodb:*", + "kms:*", + "logs:*", + "application-autoscaling:*", +} + +type stsClienter interface { + GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) + AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) +} + +// iamClienter is an interface for IAM operations (CreateRole, AttachRolePolicy, etc.). +type iamClienter interface { + CreatePolicy(ctx context.Context, params *iam.CreatePolicyInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) + CreateRole(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) + AttachRolePolicy(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) + GetRole(ctx context.Context, params *iam.GetRoleInput, optFns ...func(*iam.Options)) (*iam.GetRoleOutput, error) + GetPolicy(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) +} + +type checkerClienter interface { + CanRoleDoAction(ctx context.Context, roleArn string, actions []string) (bool, error) +} + +func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamClient iamClienter, checker checkerClienter, roleArn, clusterName string) (*awsTypes.Credentials, error) { + // Check who we are currently (to ensure you're properly authenticated) + callerIdentity, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return nil, fmt.Errorf("failed to get caller identity: %w", err) + } + + // If ARN is empty, create a new role with kubernetes admin permissions + if roleArn == "" { + createdArn, err := createKubernetesAdminRole(ctx, clusterName, iamClient, checker, callerIdentity) + if err != nil { + return nil, fmt.Errorf("failed to create a new role for EKS clusters: %w", err) + } + roleArn = createdArn + } + + // Check if the currently provided role can perform EKS cluster creation + // with all the sub-requirements to actually make a cluster. + canCreateCluster, err := checker.CanRoleDoAction(ctx, roleArn, wantedPermissions) + if err != nil { + return nil, fmt.Errorf("failed to check if role %q can create EKS cluster: %w", roleArn, err) + } + if !canCreateCluster { + return nil, fmt.Errorf("role %q does not have permission to create EKS clusters; required permissions: %s", roleArn, wantedPermissions) + } + + // Check if the currently provided role can perform EKS cluster creation + // with all the sub-requirements to actually make a cluster. + canCreateCluster, err = checker.CanRoleDoAction(ctx, *callerIdentity.Arn, []string{"iam:AssumeRole"}) + if err != nil { + return nil, fmt.Errorf("failed to check if user %q can assume role: %w", roleArn, err) + } + if !canCreateCluster { + return nil, fmt.Errorf("user %q does not have permission to assume roles", roleArn) + } + + // Create a session name (some unique identifier) + sessionName := fmt.Sprintf("kubefirst-session-%s", *callerIdentity.UserId) + + creds, err := assumeRoleWithRetry(ctx, stsClient, roleArn, sessionName) + if err != nil { + return nil, fmt.Errorf("failed to assume role %s: %w", roleArn, err) + } + + // // Return the credentials + return creds, nil +} + +// AdditionalRolePolicies is a slice of policy ARNs you want to attach +// to every new "Kubernetes Admin" role created by the function below. +var AdditionalRolePolicies = []string{ + "arn:aws:iam::aws:policy/AmazonEKSServicePolicy", + "arn:aws:iam::aws:policy/AmazonEKSVPCResourceController", + // Put your extra policies here, e.g.: + // "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess", +} + +func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClient iamClienter, checker checkerClienter, callerIdentity *sts.GetCallerIdentityOutput) (string, error) { + if callerIdentity.Arn == nil { + return "", errors.New("caller identity ARN is nil") + } + + // Verify that the current caller has permission to create IAM roles + wantedRolePermissions := []string{"iam:CreateRole", "iam:AssumeRole", "iam:AttachRolePolicy"} + canPerformActions, err := checker.CanRoleDoAction(ctx, aws.ToString(callerIdentity.Arn), wantedRolePermissions) + if err != nil { + return "", fmt.Errorf("failed to check permission to create a new role: %w", err) + } + if !canPerformActions { + return "", fmt.Errorf("caller %q does not have the required permissions: %s", aws.ToString(callerIdentity.Arn), wantedRolePermissions) + } + + // Build a custom policy that allows EKS operations + permissionPolicy := map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{{ + "Effect": "Allow", + "Action": wantedPermissions, + "Resource": "*", + }}, + } + + // Encode the policy as JSON + permissionPolicyBytes, err := json.Marshal(permissionPolicy) + if err != nil { + return "", fmt.Errorf("failed to marshal permission policy JSON: %w", err) + } + + // Policy name + policyName := fmt.Sprintf("KubefirstKubernetesAdminPolicy-%s", clusterName) + + // Check if the IAM policy exists + cp, err := iamClient.GetPolicy(ctx, &iam.GetPolicyInput{PolicyArn: aws.String(fmt.Sprintf("arn:aws:iam::%s:policy/%s", *callerIdentity.Account, policyName))}) + if err != nil { + var newError *awshttp.ResponseError + if errors.As(err, &newError) && newError.HTTPStatusCode() != http.StatusNotFound { + return "", fmt.Errorf("failed to get policy %q: %w", policyName, err) + } + } + + if err == nil && cp.Policy != nil { + return "", fmt.Errorf("policy %q already exists: please delete the policy and try again", policyName) + } + + // Create the policy in IAM + cpo, err := iamClient.CreatePolicy(ctx, &iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(string(permissionPolicyBytes)), + Description: aws.String("Policy that allows creating EKS clusters"), + }) + if err != nil { + return "", fmt.Errorf("failed to create policy %q: %w", policyName, err) + } + + // Build a trust policy that allows the current caller to assume the new role. + trustPolicy := map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{{ + "Effect": "Allow", + "Principal": map[string]interface{}{ + // Instead of callerIdentity.Arn, we could do: + // "AWS": fmt.Sprintf("arn:aws:iam::%s:root", accountId) + // to allow any principal in your account to assume. + "AWS": aws.ToString(callerIdentity.Arn), + }, + "Action": "sts:AssumeRole", + }}, + } + + // Encode the trust policy as JSON + trustPolicyBytes, err := json.Marshal(trustPolicy) + if err != nil { + return "", fmt.Errorf("failed to marshal trust policy JSON: %w", err) + } + + // Check if the IAM role exists + roleName := fmt.Sprintf("KubefirstKubernetesAdminRole-%s", clusterName) + + // Check if a role with this name already exists + role, err := iamClient.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(roleName)}) + if err != nil { + var newError *awshttp.ResponseError + if errors.As(err, &newError) && newError.HTTPStatusCode() != http.StatusNotFound { + return "", fmt.Errorf("failed to get role %q: %w", roleName, err) + } + } + + if err == nil && role.Role != nil { + return "", fmt.Errorf("role %q already exists: please delete the role and try again", roleName) + } + + // Create the IAM role + createOut, err := iamClient.CreateRole(ctx, &iam.CreateRoleInput{ + RoleName: aws.String(roleName), + AssumeRolePolicyDocument: aws.String(string(trustPolicyBytes)), + MaxSessionDuration: aws.Int32(sessionDuration), + Description: aws.String("Role that can create EKS clusters"), + }) + if err != nil { + return "", fmt.Errorf("failed to create role %q: %w", roleName, err) + } + + // Attach a policy that lets this role create EKS clusters. + // The AWS-managed policy "AmazonEKSClusterPolicy" covers cluster creation & management. + // Real usage often requires more policies for VPC, node groups, etc. + _, err = iamClient.AttachRolePolicy(ctx, &iam.AttachRolePolicyInput{ + RoleName: createOut.Role.RoleName, + PolicyArn: aws.String("arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"), + }) + if err != nil { + return "", fmt.Errorf("failed to attach AmazonEKSClusterPolicy to role %q: %w", roleName, err) + } + + // Attach a custom policy that allows EKS operations + _, err = iamClient.AttachRolePolicy(ctx, &iam.AttachRolePolicyInput{ + RoleName: createOut.Role.RoleName, + PolicyArn: cpo.Policy.Arn, + }) if err != nil { - return nil, fmt.Errorf("failed to retrieve AWS credentials: %w", err) + return "", fmt.Errorf("failed to attach custom policy %q to role %q: %w", policyName, roleName, err) + } + + // Attach any additional role policies from the package-level slice + for _, policyArn := range AdditionalRolePolicies { + _, err := iamClient.AttachRolePolicy(ctx, &iam.AttachRolePolicyInput{ + RoleName: createOut.Role.RoleName, + PolicyArn: aws.String(policyArn), + }) + if err != nil { + return "", fmt.Errorf("failed to attach policy %q to role %q: %w", policyArn, roleName, err) + } } - return &creds, nil + // Return the new role ARN + return aws.ToString(createOut.Role.Arn), nil } func validateAMIType(ctx context.Context, amiType, nodeType string, ssmClient ssmClienter, ec2Client ec2Clienter, paginator paginator) error { @@ -155,3 +420,21 @@ func getSupportedInstanceTypes(ctx context.Context, p paginator, architecture st } return instanceTypes, nil } + +func assumeRoleWithRetry(ctx context.Context, stsClient stsClienter, roleArn, sessionName string) (*awsTypes.Credentials, error) { + for attempt := 1; attempt <= maxRetries; attempt++ { + output, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ + RoleArn: aws.String(roleArn), + RoleSessionName: aws.String(sessionName), + DurationSeconds: aws.Int32(sessionDuration), + }) + if err == nil { + return output.Credentials, nil + } + + delay := time.Duration(attempt*attempt) * baseDelay + time.Sleep(delay) + } + + return nil, fmt.Errorf("failed to assume role") +} diff --git a/cmd/aws/create_test.go b/cmd/aws/create_test.go index a608e7648..15b21d186 100644 --- a/cmd/aws/create_test.go +++ b/cmd/aws/create_test.go @@ -11,47 +11,223 @@ import ( "github.com/aws/aws-sdk-go-v2/service/ssm" ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" "github.com/stretchr/testify/require" + + "github.com/aws/aws-sdk-go-v2/service/iam" + iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/aws-sdk-go-v2/service/sts/types" ) +type mockStsClient struct { + FnGetCallerIdentity func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) + FnAssumeRole func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) +} + +func (m *mockStsClient) GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + if m.FnGetCallerIdentity != nil { + return m.FnGetCallerIdentity(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +func (m *mockStsClient) AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + if m.FnAssumeRole != nil { + return m.FnAssumeRole(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +type mockIamClient struct { + FnCreateRole func(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) + FnAttachRolePolicy func(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) + FnCreatePolicy func(ctx context.Context, params *iam.CreatePolicyInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) + FnGetPolicy func(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) + FnGetRole func(ctx context.Context, params *iam.GetRoleInput, optFns ...func(*iam.Options)) (*iam.GetRoleOutput, error) +} + +func (m *mockIamClient) CreateRole(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) { + if m.FnCreateRole != nil { + return m.FnCreateRole(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +func (m *mockIamClient) AttachRolePolicy(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) { + if m.FnAttachRolePolicy != nil { + return m.FnAttachRolePolicy(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +func (m *mockIamClient) CreatePolicy(ctx context.Context, params *iam.CreatePolicyInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) { + if m.FnCreatePolicy != nil { + return m.FnCreatePolicy(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +func (m *mockIamClient) GetPolicy(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) { + if m.FnGetPolicy != nil { + return m.FnGetPolicy(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +func (m *mockIamClient) GetRole(ctx context.Context, params *iam.GetRoleInput, optFns ...func(*iam.Options)) (*iam.GetRoleOutput, error) { + if m.FnGetRole != nil { + return m.FnGetRole(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +type mockChecker struct { + FnCanRoleDoAction func(ctx context.Context, roleArn string, actions []string) (bool, error) +} + +func (m *mockChecker) CanRoleDoAction(ctx context.Context, roleArn string, actions []string) (bool, error) { + if m.FnCanRoleDoAction != nil { + return m.FnCanRoleDoAction(ctx, roleArn, actions) + } + return false, errors.New("not implemented") +} + func TestValidateCredentials(t *testing.T) { + ctx := context.Background() + tests := []struct { - name string - creds aws.Credentials - err error - expectedErr error + name string + roleARN string + mockStsClient *mockStsClient + mockIamClient *mockIamClient // only required if roleARN is empty + mockChecker *mockChecker + wantErr bool + expectedUserId string + expectedSessionId string }{ { - name: "valid credentials", - creds: aws.Credentials{ - AccessKeyID: "test-access-key-id", - SecretAccessKey: "test-secret-access-key", - SessionToken: "test-session-token", + name: "successful conversion", + roleARN: "arn:aws:iam::123456789012:role/example-role", + mockStsClient: &mockStsClient{ + FnGetCallerIdentity: func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return &sts.GetCallerIdentityOutput{ + UserId: aws.String("user-123"), + Arn: aws.String("arn:aws:iam::123456789012:user/user-123"), + }, nil + }, + FnAssumeRole: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + if *params.RoleArn != "arn:aws:iam::123456789012:role/example-role" { + t.Fatalf("unexpected role ARN: %s", *params.RoleArn) + } + + return &sts.AssumeRoleOutput{ + Credentials: &types.Credentials{ + AccessKeyId: aws.String("access-key-id"), + SecretAccessKey: aws.String("secret-access-key"), + SessionToken: aws.String("session-token"), + }, + }, nil + }, + }, + mockChecker: &mockChecker{ + FnCanRoleDoAction: func(ctx context.Context, roleArn string, actions []string) (bool, error) { + return true, nil + }, }, - err: nil, + expectedUserId: "user-123", + expectedSessionId: "kubefirst-session-user-123", }, { - name: "failed to retrieve credentials", - creds: aws.Credentials{}, - err: errors.New("failed to retrieve credentials"), + name: "failed to get caller identity", + roleARN: "arn:aws:iam::123456789012:role/example-role", + mockStsClient: &mockStsClient{ + FnGetCallerIdentity: func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return nil, errors.New("failed to get caller identity") + }, + }, + wantErr: true, + }, + { + name: "role does not have create cluster permission", + roleARN: "arn:aws:iam::123456789012:role/example-role", + mockStsClient: &mockStsClient{ + FnGetCallerIdentity: func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return &sts.GetCallerIdentityOutput{ + UserId: aws.String("user-123"), + }, nil + }, + }, + mockChecker: &mockChecker{ + FnCanRoleDoAction: func(ctx context.Context, roleArn string, actions []string) (bool, error) { + return false, nil + }, + }, + wantErr: true, + }, + { + name: "role ARN is empty", + roleARN: "", + mockIamClient: &mockIamClient{ + FnCreateRole: func(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) { + return &iam.CreateRoleOutput{ + Role: &iamTypes.Role{ + Arn: aws.String("arn:aws:iam::123456789012:role/example-role"), + }, + }, nil + }, + FnAttachRolePolicy: func(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) { + return &iam.AttachRolePolicyOutput{}, nil + }, + FnCreatePolicy: func(ctx context.Context, params *iam.CreatePolicyInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) { + return &iam.CreatePolicyOutput{ + Policy: &iamTypes.Policy{ + Arn: aws.String("arn:aws:iam::123456789012:policy/example-policy"), + }, + }, nil + }, + }, + mockStsClient: &mockStsClient{ + FnGetCallerIdentity: func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return &sts.GetCallerIdentityOutput{ + UserId: aws.String("user-123"), + }, nil + }, + FnAssumeRole: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + if *params.RoleArn != "arn:aws:iam::123456789012:role/example-role" { + t.Fatalf("unexpected role ARN: %s", *params.RoleArn) + } + + return &sts.AssumeRoleOutput{ + Credentials: &types.Credentials{ + AccessKeyId: aws.String("access-key-id"), + SecretAccessKey: aws.String("secret-access-key"), + SessionToken: aws.String("session-token"), + }, + }, nil + }, + }, + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mockProvider := &mockCredentialsProvider{ - fnRetrieve: func(ctx context.Context) (aws.Credentials, error) { - return tt.creds, tt.err - }, - } - - creds, err := getSessionCredentials(context.Background(), mockProvider) - - if tt.err != nil { - require.ErrorContains(t, err, tt.err.Error()) + clusterName := "foobar" + credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, tt.mockIamClient, tt.mockChecker, tt.roleARN, clusterName) + if tt.wantErr { + require.Error(t, err) } else { - require.NotNil(t, creds) require.NoError(t, err) - require.Equal(t, tt.creds, *creds) + require.NotNil(t, credentials) + require.Equal(t, "access-key-id", *credentials.AccessKeyId) + require.Equal(t, "secret-access-key", *credentials.SecretAccessKey) + require.Equal(t, "session-token", *credentials.SessionToken) } }) } diff --git a/internal/aws/aws.go b/internal/aws/aws.go new file mode 100644 index 000000000..7242a82ca --- /dev/null +++ b/internal/aws/aws.go @@ -0,0 +1,65 @@ +package aws + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/iam" +) + +type iamClienter interface { + SimulatePrincipalPolicy(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) +} + +// Checker is a struct that holds an IAM client +// to check if a role can perform a given action +type Checker struct { + IAMClient iamClienter +} + +// NewChecker creates a new AWSChecker instance +func NewChecker(ctx context.Context) (*Checker, error) { + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + iamClient := iam.NewFromConfig(cfg) + return &Checker{IAMClient: iamClient}, nil +} + +// CanRoleDoAction calls the IAM Policy Simulator for a given set of permissions +// For example, to know if a role can create an EKS cluster, you can call this function +// with the role ARN and the action "eks:CreateCluster": +// +// canCreateCluster, err := CanRoleDoAction(ctx, "arn:aws:iam::123456789012:role/MyRole", []string{"eks:CreateCluster"}) +func (a *Checker) CanRoleDoAction(ctx context.Context, roleArn string, actions []string) (bool, error) { + simulateOutput, err := a.IAMClient.SimulatePrincipalPolicy(ctx, &iam.SimulatePrincipalPolicyInput{ + PolicySourceArn: aws.String(roleArn), + ActionNames: actions, + ResourceArns: []string{"*"}, // or a more specific ARN if you want + }) + if err != nil { + return false, fmt.Errorf("policy simulation error: %w", err) + } + + // Track allowed actions + allowedActions := make(map[string]bool) + + // Evaluate simulation results for each action + for _, res := range simulateOutput.EvaluationResults { + if res.EvalActionName != nil && res.EvalDecision == "allowed" { + allowedActions[*res.EvalActionName] = true + } + } + + // Ensure all actions are allowed + for _, action := range actions { + if !allowedActions[action] { + return false, nil + } + } + + return true, nil +} diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go new file mode 100644 index 000000000..e481fb614 --- /dev/null +++ b/internal/aws/aws_test.go @@ -0,0 +1,127 @@ +package aws + +import ( + "context" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/stretchr/testify/require" +) + +type mockIamClient struct { + FnSimulatePrincipalPolicy func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) +} + +func (m *mockIamClient) SimulatePrincipalPolicy(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + if m.FnSimulatePrincipalPolicy != nil { + return m.FnSimulatePrincipalPolicy(ctx, params, optFns...) + } + return nil, errors.New("not implemented") +} + +func TestCanRoleDoAction(t *testing.T) { + tests := []struct { + name string + mockFn func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) + roleArn string + actions []string + expectedResult bool + expectedError error + }{ + { + name: "all actions allowed", + mockFn: func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + return &iam.SimulatePrincipalPolicyOutput{ + EvaluationResults: []types.EvaluationResult{ + { + EvalActionName: aws.String("eks:CreateCluster"), + EvalDecision: "allowed", + }, + { + EvalActionName: aws.String("s3:PutObject"), + EvalDecision: "allowed", + }, + }, + }, nil + }, + roleArn: "arn:aws:iam::123456789012:role/MyRole", + actions: []string{"eks:CreateCluster", "s3:PutObject"}, + expectedResult: true, + expectedError: nil, + }, + { + name: "missing action", + mockFn: func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + return &iam.SimulatePrincipalPolicyOutput{ + EvaluationResults: []types.EvaluationResult{ + { + EvalActionName: aws.String("eks:CreateCluster"), + EvalDecision: "allowed", + }, + { + EvalActionName: aws.String("s3:PutObject"), + EvalDecision: "allowed", + }, + }, + }, nil + }, + roleArn: "arn:aws:iam::123456789012:role/MyRole", + actions: []string{"eks:CreateCluster", "s3:PutObject", "s3:GetObject"}, + expectedResult: false, + expectedError: nil, + }, + { + name: "some actions not allowed", + mockFn: func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + return &iam.SimulatePrincipalPolicyOutput{ + EvaluationResults: []types.EvaluationResult{ + { + EvalActionName: aws.String("eks:CreateCluster"), + EvalDecision: "allowed", + }, + { + EvalActionName: aws.String("s3:PutObject"), + EvalDecision: "explicitDeny", + }, + }, + }, nil + }, + roleArn: "arn:aws:iam::123456789012:role/MyRole", + actions: []string{"eks:CreateCluster", "s3:PutObject"}, + expectedResult: false, + expectedError: nil, + }, + { + name: "simulate policy error", + mockFn: func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + return nil, errors.New("simulation failed") + }, + roleArn: "arn:aws:iam::123456789012:role/MyRole", + actions: []string{"eks:CreateCluster"}, + expectedResult: false, + expectedError: errors.New("policy simulation error: simulation failed"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mockClient := &mockIamClient{ + FnSimulatePrincipalPolicy: test.mockFn, + } + checker := &Checker{IAMClient: mockClient} + result, err := checker.CanRoleDoAction(context.Background(), test.roleArn, test.actions) + + if test.expectedError != nil { + require.Error(t, err) + require.EqualError(t, err, test.expectedError.Error()) + } else { + require.NoError(t, err) + } + + require.Equal(t, test.expectedResult, result) + }) + } +} diff --git a/internal/launch/cmd.go b/internal/launch/cmd.go index 7246bfc8d..3c82b1f69 100644 --- a/internal/launch/cmd.go +++ b/internal/launch/cmd.go @@ -189,7 +189,7 @@ func Up(ctx context.Context, additionalHelmFlags []string, inCluster, useTelemet "kubeconfig", "get", consoleClusterName, - "--output", + "-o", kubeconfigPath, ) if err != nil { diff --git a/internal/types/flags.go b/internal/types/flags.go index 584b88482..70c7720f7 100644 --- a/internal/types/flags.go +++ b/internal/types/flags.go @@ -36,4 +36,5 @@ type CliFlags struct { K3sServersArgs []string InstallKubefirstPro bool AMIType string + KubeAdminRoleARN string } From bb7e760cf3da077dcb7610ddfd74fa25c142f083 Mon Sep 17 00:00:00 2001 From: mrrishi Date: Wed, 12 Feb 2025 03:30:22 +0530 Subject: [PATCH 2/2] feat: handle kubeconfig for k3d --- internal/launch/cmd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/launch/cmd.go b/internal/launch/cmd.go index 3c82b1f69..11f3251dd 100644 --- a/internal/launch/cmd.go +++ b/internal/launch/cmd.go @@ -187,7 +187,7 @@ func Up(ctx context.Context, additionalHelmFlags []string, inCluster, useTelemet _, _, err := shell.ExecShellReturnStrings( k3dClient, "kubeconfig", - "get", + "write", consoleClusterName, "-o", kubeconfigPath,