From b33ca86a79fbe503d6db9117b535362e4b1d1548 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Mon, 13 Jan 2025 12:25:59 -0500 Subject: [PATCH 01/25] Fix catalog apps generation and validation. --- cmd/akamai/create.go | 6 ++-- cmd/aws/create.go | 6 ++-- cmd/azure/create.go | 7 ++--- cmd/civo/create.go | 6 ++-- cmd/digitalocean/create.go | 9 ++---- cmd/google/create.go | 6 ++-- cmd/k3d/create.go | 6 +--- cmd/k3s/create.go | 8 ++--- cmd/vultr/create.go | 9 ++---- internal/catalog/catalog.go | 60 ++++++++++++++++++------------------- 10 files changed, 51 insertions(+), 72 deletions(-) diff --git a/cmd/akamai/create.go b/cmd/akamai/create.go index 682308132..f2bc6bbff 100644 --- a/cmd/akamai/create.go +++ b/cmd/akamai/create.go @@ -34,9 +34,9 @@ func createAkamai(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(25) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) - if !isValid { - return fmt.Errorf("catalog validation failed: %w", err) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + if err != nil { + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index d39a8e440..bb2b3a50c 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -41,9 +41,9 @@ func createAws(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(40) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) - if !isValid { - return fmt.Errorf("invalid catalog apps: %w", err) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + if err != nil { + return fmt.Errorf("failed to validate catalog apps: %w", err) } ctx := cmd.Context() diff --git a/cmd/azure/create.go b/cmd/azure/create.go index 4297229fd..c64c1b48b 100644 --- a/cmd/azure/create.go +++ b/cmd/azure/create.go @@ -45,10 +45,9 @@ func createAzure(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(20) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) - if !isValid { - progress.Error(err.Error()) - return nil + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + if err != nil { + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider) diff --git a/cmd/civo/create.go b/cmd/civo/create.go index 14592cea4..8abc49124 100644 --- a/cmd/civo/create.go +++ b/cmd/civo/create.go @@ -34,9 +34,9 @@ func createCivo(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(15) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) - if !isValid { - return fmt.Errorf("catalog apps validation failed: %w", err) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + if err != nil { + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider) diff --git a/cmd/digitalocean/create.go b/cmd/digitalocean/create.go index c12134962..09895f072 100644 --- a/cmd/digitalocean/create.go +++ b/cmd/digitalocean/create.go @@ -7,7 +7,6 @@ See the LICENSE file for more details. package digitalocean import ( - "errors" "fmt" "os" "strings" @@ -35,13 +34,9 @@ func createDigitalocean(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(20) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) if err != nil { - return fmt.Errorf("catalog validation error: %w", err) - } - - if !isValid { - return errors.New("catalog did not pass a validation check") + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider) diff --git a/cmd/google/create.go b/cmd/google/create.go index bb074d9bb..b2466bb8e 100644 --- a/cmd/google/create.go +++ b/cmd/google/create.go @@ -35,9 +35,9 @@ func createGoogle(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(20) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) - if !isValid { - return fmt.Errorf("catalog apps validation failed: %w", err) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + if err != nil { + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider) diff --git a/cmd/k3d/create.go b/cmd/k3d/create.go index adedfa5a9..21988ed06 100644 --- a/cmd/k3d/create.go +++ b/cmd/k3d/create.go @@ -121,15 +121,11 @@ func runK3d(cmd *cobra.Command, _ []string) error { utilities.CreateK1ClusterDirectory(clusterNameFlag) utils.DisplayLogHints() - isValid, catalogApps, err := catalog.ValidateCatalogApps(installCatalogAppsFlag) + catalogApps, err := catalog.ValidateCatalogApps(installCatalogAppsFlag) if err != nil { return fmt.Errorf("failed to validate catalog apps: %w", err) } - if !isValid { - return errors.New("catalog apps validation failed") - } - switch gitProviderFlag { case "github": key, err := internalssh.GetHostKey("github.com") diff --git a/cmd/k3s/create.go b/cmd/k3s/create.go index 038337759..74c1695d6 100644 --- a/cmd/k3s/create.go +++ b/cmd/k3s/create.go @@ -37,13 +37,9 @@ func createK3s(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(20) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) if err != nil { - return fmt.Errorf("validation of catalog apps failed: %w", err) - } - - if !isValid { - return errors.New("catalog validation failed") + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider) diff --git a/cmd/vultr/create.go b/cmd/vultr/create.go index 6086ae2b4..ac26a411b 100644 --- a/cmd/vultr/create.go +++ b/cmd/vultr/create.go @@ -7,7 +7,6 @@ See the LICENSE file for more details. package vultr import ( - "errors" "fmt" "os" "strings" @@ -35,13 +34,9 @@ func createVultr(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(15) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) if err != nil { - return fmt.Errorf("catalog apps validation failed: %w", err) - } - - if !isValid { - return errors.New("catalog validation failed") + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider) diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 1cd7e9d88..171f3f629 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -17,7 +17,6 @@ import ( apiTypes "github.com/konstructio/kubefirst-api/pkg/types" - "github.com/rs/zerolog/log" "gopkg.in/yaml.v3" ) @@ -36,71 +35,69 @@ func NewGitHub() *git.Client { return git.NewClient(nil) } -func ReadActiveApplications() (apiTypes.GitopsCatalogApps, error) { +func ReadActiveApplications() (*apiTypes.GitopsCatalogApps, error) { gh := GitHubClient{ Client: NewGitHub(), } activeContent, err := gh.ReadGitopsCatalogRepoContents() if err != nil { - return apiTypes.GitopsCatalogApps{}, fmt.Errorf("error retrieving gitops catalog repository content: %w", err) + return nil, fmt.Errorf("error retrieving gitops catalog repository content: %w", err) } index, err := gh.ReadGitopsCatalogIndex(activeContent) if err != nil { - return apiTypes.GitopsCatalogApps{}, fmt.Errorf("error retrieving gitops catalog index content: %w", err) + return nil, fmt.Errorf("error retrieving gitops catalog index content: %w", err) } var out apiTypes.GitopsCatalogApps err = yaml.Unmarshal(index, &out) if err != nil { - return apiTypes.GitopsCatalogApps{}, fmt.Errorf("error retrieving gitops catalog applications: %w", err) + return nil, fmt.Errorf("error retrieving gitops catalog applications: %w", err) } - return out, nil + return &out, nil } -func ValidateCatalogApps(catalogApps string) (bool, []apiTypes.GitopsCatalogApp, error) { - items := strings.Split(catalogApps, ",") - - gitopsCatalogapps := []apiTypes.GitopsCatalogApp{} +func ValidateCatalogApps(catalogApps string) ([]apiTypes.GitopsCatalogApp, error) { if catalogApps == "" { - return true, gitopsCatalogapps, nil + // No catalog apps to install + return nil, nil } apps, err := ReadActiveApplications() if err != nil { - log.Error().Msgf("error getting gitops catalog applications: %s", err) - return false, gitopsCatalogapps, err + return nil, err } + items := strings.Split(catalogApps, ",") + gitopsCatalogapps := make([]apiTypes.GitopsCatalogApp, 0, len(items)) for _, app := range items { found := false + for _, catalogApp := range apps.Apps { if app == catalogApp.Name { found = true - if catalogApp.SecretKeys != nil { - for _, secret := range catalogApp.SecretKeys { - secretValue := os.Getenv(secret.Env) - - if secretValue == "" { - return false, gitopsCatalogapps, fmt.Errorf("your %q environment variable is not set for %q catalog application. Please set and try again", secret.Env, app) - } - - secret.Value = secretValue + for pos, secret := range catalogApp.SecretKeys { + secretValue := os.Getenv(secret.Env) + if secretValue == "" { + return nil, fmt.Errorf("your %q environment variable is not set for %q catalog application. Please set and try again", secret.Env, app) } + + secret.Value = secretValue + catalogApp.SecretKeys[pos] = secret } - if catalogApp.ConfigKeys != nil { - for _, config := range catalogApp.ConfigKeys { - configValue := os.Getenv(config.Env) - if configValue == "" { - return false, gitopsCatalogapps, fmt.Errorf("your %q environment variable is not set for %q catalog application. Please set and try again", config.Env, app) - } - config.Value = configValue + for pos, config := range catalogApp.ConfigKeys { + configValue := os.Getenv(config.Env) + if configValue == "" { + return nil, fmt.Errorf("your %q environment variable is not set for %q catalog application. Please set and try again", config.Env, app) } + + config.Value = configValue + catalogApp.ConfigKeys[pos] = config } gitopsCatalogapps = append(gitopsCatalogapps, catalogApp) @@ -108,12 +105,13 @@ func ValidateCatalogApps(catalogApps string) (bool, []apiTypes.GitopsCatalogApp, break } } + if !found { - return false, gitopsCatalogapps, fmt.Errorf("catalog app is not supported: %q", app) + return nil, fmt.Errorf("catalog app is not supported: %q", app) } } - return true, gitopsCatalogapps, nil + return gitopsCatalogapps, nil } func (gh *GitHubClient) ReadGitopsCatalogRepoContents() ([]*git.RepositoryContent, error) { From 07567f128536d85b4aff927cafeca747a00d9ffa Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:15:14 -0500 Subject: [PATCH 02/25] AWS credential handling: pick local configuration then assume a role. --- cmd/aws/command.go | 69 ++++++++---------------- cmd/aws/create.go | 73 ++++++++++++++++++-------- cmd/aws/create_test.go | 102 +++++++++++++++++++++++++++--------- internal/catalog/catalog.go | 2 - internal/types/flags.go | 1 + internal/utilities/flags.go | 46 +++++++++++++--- 6 files changed, 190 insertions(+), 103 deletions(-) diff --git a/cmd/aws/command.go b/cmd/aws/command.go index a7283b51a..a444e4356 100644 --- a/cmd/aws/command.go +++ b/cmd/aws/command.go @@ -16,29 +16,6 @@ import ( ) var ( - // Create - alertsEmailFlag string - ciFlag bool - cloudRegionFlag string - clusterNameFlag string - clusterTypeFlag string - dnsProviderFlag string - githubOrgFlag string - gitlabGroupFlag string - gitProviderFlag string - gitProtocolFlag string - gitopsTemplateURLFlag string - gitopsTemplateBranchFlag string - domainNameFlag string - subdomainNameFlag string - useTelemetryFlag bool - ecrFlag bool - nodeTypeFlag string - nodeCountFlag string - installCatalogApps string - installKubefirstProFlag bool - amiType string - // Supported argument arrays supportedDNSProviders = []string{"aws", "cloudflare"} supportedGitProviders = []string{"github", "gitlab"} @@ -70,7 +47,6 @@ func NewCommand() *cobra.Command { // wire up new commands awsCmd.AddCommand(Create(), Destroy(), Quota(), RootCredentials()) - return awsCmd } @@ -86,29 +62,30 @@ func Create() *cobra.Command { awsDefaults := constants.GetCloudDefaults().Aws // todo review defaults and update descriptions - createCmd.Flags().StringVar(&alertsEmailFlag, "alerts-email", "", "email address for let's encrypt certificate notifications (required)") + createCmd.Flags().String("alerts-email", "", "email address for let's encrypt certificate notifications (required)") createCmd.MarkFlagRequired("alerts-email") - createCmd.Flags().BoolVar(&ciFlag, "ci", false, "if running kubefirst in ci, set this flag to disable interactive features") - createCmd.Flags().StringVar(&cloudRegionFlag, "cloud-region", "us-east-1", "the aws region to provision infrastructure in") - createCmd.Flags().StringVar(&clusterNameFlag, "cluster-name", "kubefirst", "the name of the cluster to create") - createCmd.Flags().StringVar(&clusterTypeFlag, "cluster-type", "mgmt", "the type of cluster to create (i.e. mgmt|workload)") - createCmd.Flags().StringVar(&nodeCountFlag, "node-count", awsDefaults.NodeCount, "the node count for the cluster") - createCmd.Flags().StringVar(&nodeTypeFlag, "node-type", awsDefaults.InstanceSize, "the instance size of the cluster to create") - createCmd.Flags().StringVar(&dnsProviderFlag, "dns-provider", "aws", fmt.Sprintf("the dns provider - one of: %q", supportedDNSProviders)) - createCmd.Flags().StringVar(&subdomainNameFlag, "subdomain", "", "the subdomain to use for DNS records (Cloudflare)") - createCmd.Flags().StringVar(&domainNameFlag, "domain-name", "", "the Route53/Cloudflare hosted zone name to use for DNS records (i.e. your-domain.com|subdomain.your-domain.com) (required)") + createCmd.Flags().Bool("ci", false, "if running kubefirst in ci, set this flag to disable interactive features") + createCmd.Flags().String("cloud-region", "us-east-1", "the aws region to provision infrastructure in") + createCmd.Flags().String("cluster-name", "kubefirst", "the name of the cluster to create") + createCmd.Flags().String("cluster-type", "mgmt", "the type of cluster to create (i.e. mgmt|workload)") + createCmd.Flags().String("node-count", awsDefaults.NodeCount, "the node count for the cluster") + createCmd.Flags().String("node-type", awsDefaults.InstanceSize, "the instance size of the cluster to create") + createCmd.Flags().String("dns-provider", "aws", fmt.Sprintf("the dns provider - one of: %q", supportedDNSProviders)) + createCmd.Flags().String("subdomain", "", "the subdomain to use for DNS records (Cloudflare)") + createCmd.Flags().String("domain-name", "", "the Route53/Cloudflare hosted zone name to use for DNS records (i.e. your-domain.com|subdomain.your-domain.com) (required)") createCmd.MarkFlagRequired("domain-name") - createCmd.Flags().StringVar(&gitProviderFlag, "git-provider", "github", fmt.Sprintf("the git provider - one of: %q", supportedGitProviders)) - createCmd.Flags().StringVar(&gitProtocolFlag, "git-protocol", "ssh", fmt.Sprintf("the git protocol - one of: %q", supportedGitProtocolOverride)) - createCmd.Flags().StringVar(&githubOrgFlag, "github-org", "", "the GitHub organization for the new gitops and metaphor repositories - required if using github") - createCmd.Flags().StringVar(&gitlabGroupFlag, "gitlab-group", "", "the GitLab group for the new gitops and metaphor projects - required if using gitlab") - createCmd.Flags().StringVar(&gitopsTemplateBranchFlag, "gitops-template-branch", "", "the branch to clone for the gitops-template repository") - createCmd.Flags().StringVar(&gitopsTemplateURLFlag, "gitops-template-url", "https://github.com/konstructio/gitops-template.git", "the fully qualified url to the gitops-template repository to clone") - createCmd.Flags().StringVar(&installCatalogApps, "install-catalog-apps", "", "comma separated values to install after provision") - createCmd.Flags().BoolVar(&useTelemetryFlag, "use-telemetry", true, "whether to emit telemetry") - createCmd.Flags().BoolVar(&ecrFlag, "ecr", false, "whether or not to use ecr vs the git provider") - createCmd.Flags().BoolVar(&installKubefirstProFlag, "install-kubefirst-pro", true, "whether or not to install kubefirst pro") - createCmd.Flags().StringVar(&amiType, "ami-type", "AL2_x86_64", fmt.Sprintf("the ami type for node group - one of: %q", getSupportedAMITypes())) + createCmd.Flags().String("git-provider", "github", fmt.Sprintf("the git provider - one of: %q", supportedGitProviders)) + createCmd.Flags().String("git-protocol", "ssh", fmt.Sprintf("the git protocol - one of: %q", supportedGitProtocolOverride)) + createCmd.Flags().String("github-org", "", "the GitHub organization for the new gitops and metaphor repositories - required if using github") + createCmd.Flags().String("gitlab-group", "", "the GitLab group for the new gitops and metaphor projects - required if using gitlab") + createCmd.Flags().String("gitops-template-branch", "", "the branch to clone for the gitops-template repository") + createCmd.Flags().String("gitops-template-url", "https://github.com/konstructio/gitops-template.git", "the fully qualified url to the gitops-template repository to clone") + createCmd.Flags().String("install-catalog-apps", "", "comma separated values to install after provision") + createCmd.Flags().Bool("use-telemetry", true, "whether to emit telemetry") + createCmd.Flags().Bool("ecr", false, "whether or not to use ecr vs the git provider") + createCmd.Flags().Bool("install-kubefirst-pro", true, "whether or not to install kubefirst pro") + createCmd.Flags().String("ami-type", "AL2_x86_64", fmt.Sprintf("the ami type for node group - one of: %q", getSupportedAMITypes())) + createCmd.Flags().String("kubernetes-admin-role-arn", "", "the role arn with Kubernetes Admin privileges to use for the creation flow") return createCmd } @@ -141,7 +118,7 @@ func Quota() *cobra.Command { RunE: evalAwsQuota, } - quotaCmd.Flags().StringVar(&cloudRegionFlag, "cloud-region", "us-east-1", "the aws region to provision infrastructure in") + quotaCmd.Flags().String("cloud-region", "us-east-1", "the aws region to provision infrastructure in") return quotaCmd } diff --git a/cmd/aws/create.go b/cmd/aws/create.go index bb2b3a50c..9e79c76e6 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -8,6 +8,7 @@ package aws import ( "context" + "errors" "fmt" "os" "slices" @@ -18,6 +19,8 @@ import ( "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/ssm" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/aws-sdk-go-v2/service/sts/types" internalssh "github.com/konstructio/kubefirst-api/pkg/ssh" pkg "github.com/konstructio/kubefirst-api/pkg/utils" "github.com/konstructio/kubefirst/internal/catalog" @@ -33,10 +36,9 @@ import ( ) func createAws(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "aws") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderAWS) if err != nil { - progress.Error(err.Error()) - return nil + return fmt.Errorf("failed to get flags: %w", err) } progress.DisplayLogHints(40) @@ -53,35 +55,36 @@ func createAws(cmd *cobra.Command, _ []string) error { return fmt.Errorf("unable to load AWS SDK config: %w", err) } - err = ValidateProvidedFlags(ctx, cfg, cliFlags.GitProvider, cliFlags.AMIType, cliFlags.NodeType) + err = ValidateProvidedFlags(ctx, cfg, cliFlags.DNSProvider, cliFlags.GitProvider, cliFlags.AMIType, cliFlags.NodeType) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to validate provided flags: %w", err) } - utilities.CreateK1ClusterDirectory(cliFlags.ClusterName) - - // If cluster setup is complete, return - clusterSetupComplete := viper.GetBool("kubefirst-checks.cluster-install-complete") - if clusterSetupComplete { - err = fmt.Errorf("this cluster install process has already completed successfully") - progress.Error(err.Error()) - return nil - } - - creds, err := getSessionCredentials(ctx, cfg.Credentials) + stsClient := sts.NewFromConfig(cfg) + creds, err := convertLocalCredsToSession(ctx, stsClient, cliFlags.KubeAdminRoleARN) 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.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) } + utilities.CreateK1ClusterDirectory(cliFlags.ClusterName) + + // If cluster setup is complete, return + clusterSetupComplete := viper.GetBool("kubefirst-checks.cluster-install-complete") + if clusterSetupComplete { + err = errors.New("this cluster install process has already completed successfully") + progress.Error(err.Error()) + return nil + } + gitAuth, err := gitShim.ValidateGitCredentials(cliFlags.GitProvider, cliFlags.GithubOrg, cliFlags.GitlabGroup) if err != nil { progress.Error(err.Error()) @@ -134,11 +137,11 @@ func createAws(cmd *cobra.Command, _ []string) error { return nil } -func ValidateProvidedFlags(ctx context.Context, cfg aws.Config, gitProvider, amiType, nodeType string) error { +func ValidateProvidedFlags(ctx context.Context, cfg aws.Config, dnsProvider, gitProvider, amiType, nodeType string) error { progress.AddStep("Validate provided flags") // Validate required environment variables for dns provider - if dnsProviderFlag == "cloudflare" { + if dnsProvider == "cloudflare" { if os.Getenv("CF_API_TOKEN") == "" { return fmt.Errorf("your CF_API_TOKEN environment variable is not set. Please set and try again") } @@ -173,14 +176,38 @@ func ValidateProvidedFlags(ctx context.Context, cfg aws.Config, gitProvider, ami return nil } -func getSessionCredentials(ctx context.Context, cp aws.CredentialsProvider) (*aws.Credentials, error) { - // Retrieve credentials - creds, err := cp.Retrieve(ctx) +const ( + sessionDuration = int32(21600) // We want at least 6 hours (21,600 seconds) +) + +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) +} + +func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, roleArn string) (*types.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) + } + + // Create a session name (some unique identifier) + sessionName := fmt.Sprintf("kubefirst-session-%s", *callerIdentity.UserId) + + // Assume the role + output, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ + RoleArn: aws.String(roleArn), + RoleSessionName: aws.String(sessionName), + DurationSeconds: aws.Int32(sessionDuration), + }) if err != nil { - return nil, fmt.Errorf("failed to retrieve AWS credentials: %w", err) + return nil, fmt.Errorf("failed to assume role %s: %w", roleArn, err) } - return &creds, nil + // Return the credentials + credentials := output.Credentials + return credentials, nil } func validateAMIType(ctx context.Context, amiType, nodeType string, ssmClient ssmClienter, ec2Client ec2Clienter, paginator paginator) error { diff --git a/cmd/aws/create_test.go b/cmd/aws/create_test.go index a608e7648..5922c24be 100644 --- a/cmd/aws/create_test.go +++ b/cmd/aws/create_test.go @@ -10,48 +10,100 @@ import ( ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go-v2/service/ssm" ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/aws-sdk-go-v2/service/sts/types" "github.com/stretchr/testify/require" ) +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") +} + func TestValidateCredentials(t *testing.T) { + ctx := context.Background() + roleArn := "arn:aws:iam::123456789012:role/example-role" + tests := []struct { - name string - creds aws.Credentials - err error - expectedErr error + name string + mockStsClient *mockStsClient + 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", + 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) { + return &sts.AssumeRoleOutput{ + Credentials: &types.Credentials{ + AccessKeyId: aws.String("access-key-id"), + SecretAccessKey: aws.String("secret-access-key"), + SessionToken: aws.String("session-token"), + }, + }, nil + }, + }, + expectedUserId: "user-123", + expectedSessionId: "kubefirst-session-user-123", + }, + { + name: "failed to get caller identity", + 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") + }, }, - err: nil, + wantErr: true, }, { - name: "failed to retrieve credentials", - creds: aws.Credentials{}, - err: errors.New("failed to retrieve credentials"), + name: "failed to assume 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 + }, + FnAssumeRole: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + return nil, errors.New("failed to assume role") + }, + }, + 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()) + credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, roleArn) + 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/catalog/catalog.go b/internal/catalog/catalog.go index 171f3f629..23b0512e0 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -14,9 +14,7 @@ import ( "strings" git "github.com/google/go-github/v52/github" - apiTypes "github.com/konstructio/kubefirst-api/pkg/types" - "gopkg.in/yaml.v3" ) 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 } diff --git a/internal/utilities/flags.go b/internal/utilities/flags.go index 302f1fbd6..855386cf3 100644 --- a/internal/utilities/flags.go +++ b/internal/utilities/flags.go @@ -16,7 +16,31 @@ import ( "github.com/spf13/viper" ) -func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) { +type cloudProvider int + +func (c cloudProvider) String() string { + switch c { + case CloudProviderAWS: + return "aws" + case CloudProviderAzure: + return "azure" + case CloudProviderGoogle: + return "google" + case CloudProviderK3s: + return "k3s" + default: + return "" + } +} + +const ( + CloudProviderAWS cloudProvider = iota + 1 + CloudProviderAzure + CloudProviderGoogle + CloudProviderK3s +) + +func GetFlags(cmd *cobra.Command, cloudProvider cloudProvider) (types.CliFlags, error) { cliFlags := types.CliFlags{} alertsEmailFlag, err := cmd.Flags().GetString("alerts-email") @@ -123,7 +147,7 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) return cliFlags, fmt.Errorf("failed to get install-kubefirst-pro flag: %w", err) } - if cloudProvider == "aws" { + if cloudProvider == CloudProviderAWS { ecrFlag, err := cmd.Flags().GetBool("ecr") if err != nil { progress.Error(err.Error()) @@ -137,9 +161,15 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) return cliFlags, fmt.Errorf("failed to get ami type: %w", err) } cliFlags.AMIType = amiType + + kubernetesAdminRoleArn, err := cmd.Flags().GetString("kubernetes-admin-role-arn") + if err != nil { + return cliFlags, fmt.Errorf("failed to get kubernetes-admin-role-arn flag: %w", err) + } + cliFlags.KubeAdminRoleARN = kubernetesAdminRoleArn } - if cloudProvider == "azure" { + if cloudProvider == CloudProviderAzure { dnsAzureResourceGroup, err := cmd.Flags().GetString("dns-azure-resource-group") if err != nil { progress.Error(err.Error()) @@ -148,7 +178,7 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) cliFlags.DNSAzureRG = dnsAzureResourceGroup } - if cloudProvider == "google" { + if cloudProvider == CloudProviderGoogle { googleProject, err := cmd.Flags().GetString("google-project") if err != nil { progress.Error(err.Error()) @@ -158,7 +188,7 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) cliFlags.GoogleProject = googleProject } - if cloudProvider == "k3s" { + if cloudProvider == CloudProviderK3s { k3sServersPrivateIps, err := cmd.Flags().GetStringSlice("servers-private-ips") if err != nil { progress.Error(err.Error()) @@ -208,7 +238,7 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) cliFlags.GitopsTemplateBranch = gitopsTemplateBranchFlag cliFlags.GitopsTemplateURL = gitopsTemplateURLFlag cliFlags.UseTelemetry = useTelemetryFlag - cliFlags.CloudProvider = cloudProvider + cliFlags.CloudProvider = cloudProvider.String() cliFlags.NodeType = nodeTypeFlag cliFlags.NodeCount = nodeCountFlag cliFlags.InstallCatalogApps = installCatalogAppsFlag @@ -222,13 +252,15 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) viper.Set("flags.git-protocol", cliFlags.GitProtocol) viper.Set("flags.cloud-region", cliFlags.CloudRegion) viper.Set("kubefirst.cloud-provider", cloudProvider) - if cloudProvider == "k3s" { + + if cloudProvider == CloudProviderK3s { viper.Set("flags.servers-private-ips", cliFlags.K3sServersPrivateIPs) viper.Set("flags.servers-public-ips", cliFlags.K3sServersPublicIPs) viper.Set("flags.ssh-user", cliFlags.K3sSSHUser) viper.Set("flags.ssh-privatekey", cliFlags.K3sSSHPrivateKey) viper.Set("flags.servers-args", cliFlags.K3sServersArgs) } + if err := viper.WriteConfig(); err != nil { return cliFlags, fmt.Errorf("failed to write configuration: %w", err) } From 482fbbb449c5ef2edbe04d45143784455eb65816 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:27:01 -0500 Subject: [PATCH 03/25] Fix calls to flag parsing. Adds possibly needed AWS checker. --- cmd/akamai/create.go | 2 +- cmd/aws/create_test.go | 17 ------- cmd/azure/create.go | 2 +- cmd/civo/create.go | 2 +- cmd/digitalocean/create.go | 2 +- cmd/google/create.go | 2 +- cmd/k3s/create.go | 2 +- cmd/vultr/create.go | 2 +- internal/aws/aws.go | 58 +++++++++++++++++++++++ internal/aws/aws_test.go | 92 +++++++++++++++++++++++++++++++++++++ internal/utilities/flags.go | 12 +++++ 11 files changed, 169 insertions(+), 24 deletions(-) create mode 100644 internal/aws/aws.go create mode 100644 internal/aws/aws_test.go diff --git a/cmd/akamai/create.go b/cmd/akamai/create.go index f2bc6bbff..7fd4022f9 100644 --- a/cmd/akamai/create.go +++ b/cmd/akamai/create.go @@ -26,7 +26,7 @@ import ( ) func createAkamai(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "akamai") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderAkamai) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to get flags: %w", err) diff --git a/cmd/aws/create_test.go b/cmd/aws/create_test.go index 5922c24be..c4ee8c499 100644 --- a/cmd/aws/create_test.go +++ b/cmd/aws/create_test.go @@ -476,23 +476,6 @@ func TestValidateAMIType(t *testing.T) { } } -// Begin Mock definitions -type mockCredentialsProvider struct { - fnRetrieve func(ctx context.Context) (aws.Credentials, error) -} - -func (m *mockCredentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { - if m.fnRetrieve == nil { - return aws.Credentials{}, errors.New("not implemented") - } - - creds, err := m.fnRetrieve(ctx) - if err != nil { - return aws.Credentials{}, err - } - return creds, nil -} - type mockSSMClient struct { fnGetParameter func(ctx context.Context, input *ssm.GetParameterInput, opts ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) } diff --git a/cmd/azure/create.go b/cmd/azure/create.go index c64c1b48b..b8e914519 100644 --- a/cmd/azure/create.go +++ b/cmd/azure/create.go @@ -37,7 +37,7 @@ var envvarSecrets = []string{ } func createAzure(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "azure") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderAzure) if err != nil { progress.Error(err.Error()) return nil diff --git a/cmd/civo/create.go b/cmd/civo/create.go index 8abc49124..c1927418a 100644 --- a/cmd/civo/create.go +++ b/cmd/civo/create.go @@ -26,7 +26,7 @@ import ( ) func createCivo(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "civo") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderCivo) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to get CLI flags: %w", err) diff --git a/cmd/digitalocean/create.go b/cmd/digitalocean/create.go index 09895f072..0271d67a1 100644 --- a/cmd/digitalocean/create.go +++ b/cmd/digitalocean/create.go @@ -26,7 +26,7 @@ import ( ) func createDigitalocean(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "digitalocean") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderDigitalOcean) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to get CLI flags: %w", err) diff --git a/cmd/google/create.go b/cmd/google/create.go index b2466bb8e..768448480 100644 --- a/cmd/google/create.go +++ b/cmd/google/create.go @@ -27,7 +27,7 @@ import ( ) func createGoogle(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "google") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderGoogle) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to get flags: %w", err) diff --git a/cmd/k3s/create.go b/cmd/k3s/create.go index 74c1695d6..d7192a5d8 100644 --- a/cmd/k3s/create.go +++ b/cmd/k3s/create.go @@ -29,7 +29,7 @@ import ( ) func createK3s(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "k3s") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderK3s) if err != nil { progress.Error(err.Error()) return fmt.Errorf("error collecting flags: %w", err) diff --git a/cmd/vultr/create.go b/cmd/vultr/create.go index ac26a411b..4d2490bed 100644 --- a/cmd/vultr/create.go +++ b/cmd/vultr/create.go @@ -26,7 +26,7 @@ import ( ) func createVultr(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "vultr") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderVultr) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to get flags: %w", err) diff --git a/internal/aws/aws.go b/internal/aws/aws.go new file mode 100644 index 000000000..a41b3ebf1 --- /dev/null +++ b/internal/aws/aws.go @@ -0,0 +1,58 @@ +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) +} + +// AWSChecker is a struct that holds an IAM client +// to check if a role can perform a given action +type AWSChecker struct { + iamClient iamClienter +} + +// NewChecker creates a new AWSChecker instance +func NewChecker(ctx context.Context) (*AWSChecker, 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 &AWSChecker{iamClient: iamClient}, nil +} + +// CheckIfRoleCan 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 := CheckIfRoleCan(ctx, "arn:aws:iam::123456789012:role/MyRole", []string{"eks:CreateCluster"}) +func (a *AWSChecker) CheckIfRoleCan(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) + } + + // Evaluate simulation results + for _, res := range simulateOutput.EvaluationResults { + if res.EvalActionName != nil && *res.EvalActionName == "eks:CreateCluster" { + // res.EvalDecision is one of: allowed / explicitDeny / implicitDeny + if res.EvalDecision == "allowed" { + return true, nil + } + } + } + + return false, nil +} diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go new file mode 100644 index 000000000..1f5503f35 --- /dev/null +++ b/internal/aws/aws_test.go @@ -0,0 +1,92 @@ +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 TestCheckIfRoleCan(t *testing.T) { + ctx := context.Background() + roleArn := "arn:aws:iam::123456789012:role/MyRole" + actions := []string{"eks:CreateCluster"} + + tests := []struct { + name string + mockIamClient *mockIamClient + expectedResult bool + wantErr bool + }{ + { + name: "successful permission check", + mockIamClient: &mockIamClient{ + FnSimulatePrincipalPolicy: 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: types.PolicyEvaluationDecisionTypeAllowed, + }, + }, + }, nil + }, + }, + expectedResult: true, + }, + { + name: "permission denied", + mockIamClient: &mockIamClient{ + FnSimulatePrincipalPolicy: 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: types.PolicyEvaluationDecisionTypeExplicitDeny, + }, + }, + }, nil + }, + }, + expectedResult: false, + }, + { + name: "error from SimulatePrincipalPolicy", + mockIamClient: &mockIamClient{ + FnSimulatePrincipalPolicy: func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + return nil, errors.New("simulate policy error") + }, + }, + expectedResult: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checker := &AWSChecker{iamClient: tt.mockIamClient} + result, err := checker.CheckIfRoleCan(ctx, roleArn, actions) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedResult, result) + } + }) + } +} diff --git a/internal/utilities/flags.go b/internal/utilities/flags.go index 855386cf3..ff7ddd366 100644 --- a/internal/utilities/flags.go +++ b/internal/utilities/flags.go @@ -28,6 +28,14 @@ func (c cloudProvider) String() string { return "google" case CloudProviderK3s: return "k3s" + case CloudProviderAkamai: + return "akamai" + case CloudProviderCivo: + return "civo" + case CloudProviderDigitalOcean: + return "digitalocean" + case CloudProviderVultr: + return "vultr" default: return "" } @@ -38,6 +46,10 @@ const ( CloudProviderAzure CloudProviderGoogle CloudProviderK3s + CloudProviderAkamai + CloudProviderCivo + CloudProviderDigitalOcean + CloudProviderVultr ) func GetFlags(cmd *cobra.Command, cloudProvider cloudProvider) (types.CliFlags, error) { From a028e857b4611084696667c047d757975c81af4c Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:42:51 -0500 Subject: [PATCH 04/25] Check if user can create clusters. --- cmd/aws/create.go | 19 +++++++++-- cmd/aws/create_test.go | 74 +++++++++++++++++++++++++++++++++++++++- internal/aws/aws.go | 6 ++-- internal/aws/aws_test.go | 2 +- 4 files changed, 94 insertions(+), 7 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 9e79c76e6..a72007779 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -23,6 +23,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/sts/types" internalssh "github.com/konstructio/kubefirst-api/pkg/ssh" pkg "github.com/konstructio/kubefirst-api/pkg/utils" + internalaws "github.com/konstructio/kubefirst/internal/aws" "github.com/konstructio/kubefirst/internal/catalog" "github.com/konstructio/kubefirst/internal/cluster" "github.com/konstructio/kubefirst/internal/gitShim" @@ -62,7 +63,12 @@ func createAws(cmd *cobra.Command, _ []string) error { } stsClient := sts.NewFromConfig(cfg) - creds, err := convertLocalCredsToSession(ctx, stsClient, cliFlags.KubeAdminRoleARN) + checker, err := internalaws.NewChecker(ctx) + if err != nil { + return fmt.Errorf("failed to perform aws checks: %w", err) + } + + creds, err := convertLocalCredsToSession(ctx, stsClient, checker, cliFlags.KubeAdminRoleARN) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to retrieve AWS credentials: %w", err) @@ -185,7 +191,16 @@ type stsClienter interface { AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) } -func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, roleArn string) (*types.Credentials, error) { +func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, checker *internalaws.AWSChecker, roleArn string) (*types.Credentials, error) { + // Check if the currently provided role can perform EC2 cluster creation + canCreateCluster, err := checker.CheckIfRoleCan(ctx, roleArn, []string{"eks:CreateCluster"}) + 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", roleArn) + } + // Check who we are currently (to ensure you're properly authenticated) callerIdentity, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) if err != nil { diff --git a/cmd/aws/create_test.go b/cmd/aws/create_test.go index c4ee8c499..4d174b27c 100644 --- a/cmd/aws/create_test.go +++ b/cmd/aws/create_test.go @@ -8,10 +8,13 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "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" + iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/aws/aws-sdk-go-v2/service/ssm" ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/aws/aws-sdk-go-v2/service/sts/types" + internalaws "github.com/konstructio/kubefirst/internal/aws" "github.com/stretchr/testify/require" ) @@ -36,13 +39,49 @@ func (m *mockStsClient) AssumeRole(ctx context.Context, params *sts.AssumeRoleIn return nil, errors.New("not implemented") } +type mockAWSSimulator struct { + FnSimulatePrincipalPolicy func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) +} + +func (m *mockAWSSimulator) 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 TestValidateCredentials(t *testing.T) { ctx := context.Background() roleArn := "arn:aws:iam::123456789012:role/example-role" + fnGenerateSimulator := func(actionName, decision string, err error) func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + return func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + if err != nil { + return nil, err + } + + return &iam.SimulatePrincipalPolicyOutput{ + EvaluationResults: []iamTypes.EvaluationResult{ + { + EvalActionName: aws.String(actionName), + EvalDecision: iamTypes.PolicyEvaluationDecisionType(decision), + }, + }, + }, nil + } + } + + type simulator struct { + actionName string + decision string + err error + } + tests := []struct { name string mockStsClient *mockStsClient + simulator simulator wantErr bool expectedUserId string expectedSessionId string @@ -65,6 +104,10 @@ func TestValidateCredentials(t *testing.T) { }, nil }, }, + simulator: simulator{ + actionName: "eks:CreateCluster", + decision: "allowed", + }, expectedUserId: "user-123", expectedSessionId: "kubefirst-session-user-123", }, @@ -75,6 +118,10 @@ func TestValidateCredentials(t *testing.T) { return nil, errors.New("failed to get caller identity") }, }, + simulator: simulator{ + actionName: "eks:CreateCluster", + decision: "allowed", + }, wantErr: true, }, { @@ -89,13 +136,38 @@ func TestValidateCredentials(t *testing.T) { return nil, errors.New("failed to assume role") }, }, + simulator: simulator{ + actionName: "eks:CreateCluster", + decision: "allowed", + }, + wantErr: true, + }, + { + name: "role does not have create cluster permission", + simulator: simulator{ + actionName: "eks:CreateCluster", + decision: "explicitDeny", + }, + wantErr: true, + }, + { + name: "simulator verification failed", + simulator: simulator{ + err: errors.New("failed to simulate"), + }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, roleArn) + checker := &internalaws.AWSChecker{ + IAMClient: &mockAWSSimulator{ + FnSimulatePrincipalPolicy: fnGenerateSimulator(tt.simulator.actionName, tt.simulator.decision, tt.simulator.err), + }, + } + + credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, checker, roleArn) if tt.wantErr { require.Error(t, err) } else { diff --git a/internal/aws/aws.go b/internal/aws/aws.go index a41b3ebf1..4f7e4ec5b 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -16,7 +16,7 @@ type iamClienter interface { // AWSChecker is a struct that holds an IAM client // to check if a role can perform a given action type AWSChecker struct { - iamClient iamClienter + IAMClient iamClienter } // NewChecker creates a new AWSChecker instance @@ -26,7 +26,7 @@ func NewChecker(ctx context.Context) (*AWSChecker, error) { return nil, fmt.Errorf("failed to load AWS config: %w", err) } iamClient := iam.NewFromConfig(cfg) - return &AWSChecker{iamClient: iamClient}, nil + return &AWSChecker{IAMClient: iamClient}, nil } // CheckIfRoleCan calls the IAM Policy Simulator for a given set of permissions @@ -35,7 +35,7 @@ func NewChecker(ctx context.Context) (*AWSChecker, error) { // // canCreateCluster, err := CheckIfRoleCan(ctx, "arn:aws:iam::123456789012:role/MyRole", []string{"eks:CreateCluster"}) func (a *AWSChecker) CheckIfRoleCan(ctx context.Context, roleArn string, actions []string) (bool, error) { - simulateOutput, err := a.iamClient.SimulatePrincipalPolicy(ctx, &iam.SimulatePrincipalPolicyInput{ + simulateOutput, err := a.IAMClient.SimulatePrincipalPolicy(ctx, &iam.SimulatePrincipalPolicyInput{ PolicySourceArn: aws.String(roleArn), ActionNames: actions, ResourceArns: []string{"*"}, // or a more specific ARN if you want diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go index 1f5503f35..06ec91ec1 100644 --- a/internal/aws/aws_test.go +++ b/internal/aws/aws_test.go @@ -79,7 +79,7 @@ func TestCheckIfRoleCan(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - checker := &AWSChecker{iamClient: tt.mockIamClient} + checker := &AWSChecker{IAMClient: tt.mockIamClient} result, err := checker.CheckIfRoleCan(ctx, roleArn, actions) if tt.wantErr { require.Error(t, err) From ad0089f2a41672bd28916cb5f95c1f4feaed5b44 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:44:06 -0500 Subject: [PATCH 05/25] Typo in comment --- cmd/aws/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index a72007779..7e34fae99 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -192,7 +192,7 @@ type stsClienter interface { } func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, checker *internalaws.AWSChecker, roleArn string) (*types.Credentials, error) { - // Check if the currently provided role can perform EC2 cluster creation + // Check if the currently provided role can perform EKS cluster creation canCreateCluster, err := checker.CheckIfRoleCan(ctx, roleArn, []string{"eks:CreateCluster"}) if err != nil { return nil, fmt.Errorf("failed to check if role %q can create EKS cluster: %w", roleArn, err) From ef294ac97c41801759717123d04ec5b61e42eaa5 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:45:12 -0500 Subject: [PATCH 06/25] Avoid stutter. --- cmd/aws/create.go | 4 ++-- cmd/aws/create_test.go | 2 +- internal/aws/aws.go | 14 +++++++------- internal/aws/aws_test.go | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 7e34fae99..10d31e579 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -191,9 +191,9 @@ type stsClienter interface { AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) } -func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, checker *internalaws.AWSChecker, roleArn string) (*types.Credentials, error) { +func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, checker *internalaws.Checker, roleArn string) (*types.Credentials, error) { // Check if the currently provided role can perform EKS cluster creation - canCreateCluster, err := checker.CheckIfRoleCan(ctx, roleArn, []string{"eks:CreateCluster"}) + canCreateCluster, err := checker.CanRoleDoAction(ctx, roleArn, []string{"eks:CreateCluster"}) if err != nil { return nil, fmt.Errorf("failed to check if role %q can create EKS cluster: %w", roleArn, err) } diff --git a/cmd/aws/create_test.go b/cmd/aws/create_test.go index 4d174b27c..3b8f61dfb 100644 --- a/cmd/aws/create_test.go +++ b/cmd/aws/create_test.go @@ -161,7 +161,7 @@ func TestValidateCredentials(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - checker := &internalaws.AWSChecker{ + checker := &internalaws.Checker{ IAMClient: &mockAWSSimulator{ FnSimulatePrincipalPolicy: fnGenerateSimulator(tt.simulator.actionName, tt.simulator.decision, tt.simulator.err), }, diff --git a/internal/aws/aws.go b/internal/aws/aws.go index 4f7e4ec5b..c429f9a5d 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -13,28 +13,28 @@ type iamClienter interface { SimulatePrincipalPolicy(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) } -// AWSChecker is a struct that holds an IAM client +// Checker is a struct that holds an IAM client // to check if a role can perform a given action -type AWSChecker struct { +type Checker struct { IAMClient iamClienter } // NewChecker creates a new AWSChecker instance -func NewChecker(ctx context.Context) (*AWSChecker, error) { +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 &AWSChecker{IAMClient: iamClient}, nil + return &Checker{IAMClient: iamClient}, nil } -// CheckIfRoleCan calls the IAM Policy Simulator for a given set of permissions +// 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 := CheckIfRoleCan(ctx, "arn:aws:iam::123456789012:role/MyRole", []string{"eks:CreateCluster"}) -func (a *AWSChecker) CheckIfRoleCan(ctx context.Context, roleArn string, actions []string) (bool, error) { +// 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, diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go index 06ec91ec1..db9ea6a4e 100644 --- a/internal/aws/aws_test.go +++ b/internal/aws/aws_test.go @@ -79,8 +79,8 @@ func TestCheckIfRoleCan(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - checker := &AWSChecker{IAMClient: tt.mockIamClient} - result, err := checker.CheckIfRoleCan(ctx, roleArn, actions) + checker := &Checker{IAMClient: tt.mockIamClient} + result, err := checker.CanRoleDoAction(ctx, roleArn, actions) if tt.wantErr { require.Error(t, err) } else { From 09c11163ed6ad9e87277f77cdbc96b677515705b Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Tue, 14 Jan 2025 13:01:50 -0500 Subject: [PATCH 07/25] Add creating a new role ARN if one isn't specified. --- cmd/aws/create.go | 126 ++++++++++++++++++++++++++++++++++++++--- cmd/aws/create_test.go | 101 ++++++++++++++++++++++++++++++--- 2 files changed, 212 insertions(+), 15 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 10d31e579..4c52c1b95 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -8,6 +8,7 @@ package aws import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -18,6 +19,7 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "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" "github.com/aws/aws-sdk-go-v2/service/sts/types" @@ -63,12 +65,13 @@ func createAws(cmd *cobra.Command, _ []string) error { } 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) } - creds, err := convertLocalCredsToSession(ctx, stsClient, checker, cliFlags.KubeAdminRoleARN) + creds, err := convertLocalCredsToSession(ctx, stsClient, iamClient, checker, cliFlags.KubeAdminRoleARN) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to retrieve AWS credentials: %w", err) @@ -191,7 +194,28 @@ type stsClienter interface { AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) } -func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, checker *internalaws.Checker, roleArn string) (*types.Credentials, error) { +// iamClienter is an interface for IAM operations (CreateRole, AttachRolePolicy, etc.). +type iamClienter interface { + 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) +} + +func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamClient iamClienter, checker *internalaws.Checker, roleArn string) (*types.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, 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 canCreateCluster, err := checker.CanRoleDoAction(ctx, roleArn, []string{"eks:CreateCluster"}) if err != nil { @@ -201,12 +225,6 @@ func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, chec return nil, fmt.Errorf("role %q does not have permission to create EKS clusters", roleArn) } - // 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) - } - // Create a session name (some unique identifier) sessionName := fmt.Sprintf("kubefirst-session-%s", *callerIdentity.UserId) @@ -225,6 +243,98 @@ func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, chec return credentials, 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{ + // Put your extra policies here, e.g.: + // "arn:aws:iam::aws:policy/AmazonEKSServicePolicy", + // "arn:aws:iam::aws:policy/AmazonEKSVPCResourceController", + // "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess", +} + +type trustPolicyPrincipal struct { + AWS string +} + +type trustPolicyStatement struct { + Effect string + Principal trustPolicyPrincipal + Action string +} + +type trustPolicy struct { + Version string + Statement []trustPolicyStatement +} + +func createKubernetesAdminRole(ctx context.Context, iamClient iamClienter, checker *internalaws.Checker, callerIdentity *sts.GetCallerIdentityOutput) (string, error) { + // Verify that the current caller has permission to create IAM roles + canCreateRole, err := checker.CanRoleDoAction(ctx, aws.ToString(callerIdentity.Arn), []string{"iam:CreateRole"}) + if err != nil { + return "", fmt.Errorf("failed to check permission to create a new role: %w", err) + } + if !canCreateRole { + return "", fmt.Errorf("caller %q does not have permission to create IAM roles", aws.ToString(callerIdentity.Arn)) + } + + // Build a trust policy that allows the current caller to assume the new role. + trustPolicy := trustPolicy{ + Version: "2012-10-17", + Statement: []trustPolicyStatement{{ + Effect: "Allow", + Principal: trustPolicyPrincipal{ + // 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) + } + + // Create the IAM role + roleName := "KubefirstKubernetesAdmin" + createOut, err := iamClient.CreateRole(ctx, &iam.CreateRoleInput{ + RoleName: aws.String(roleName), + AssumeRolePolicyDocument: aws.String(string(trustPolicyBytes)), + 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 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 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 { ssmParameterName, ok := supportedAMITypes[amiType] if !ok { diff --git a/cmd/aws/create_test.go b/cmd/aws/create_test.go index 3b8f61dfb..6d9b850d2 100644 --- a/cmd/aws/create_test.go +++ b/cmd/aws/create_test.go @@ -51,9 +51,29 @@ func (m *mockAWSSimulator) SimulatePrincipalPolicy(ctx context.Context, params * 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) +} + +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 TestValidateCredentials(t *testing.T) { ctx := context.Background() - roleArn := "arn:aws:iam::123456789012:role/example-role" fnGenerateSimulator := func(actionName, decision string, err error) func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { return func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { @@ -80,14 +100,17 @@ func TestValidateCredentials(t *testing.T) { tests := []struct { name string + roleARN string mockStsClient *mockStsClient + mockIamClient *mockIamClient // only required if roleARN is empty simulator simulator wantErr bool expectedUserId string expectedSessionId string }{ { - name: "successful conversion", + 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{ @@ -95,6 +118,10 @@ func TestValidateCredentials(t *testing.T) { }, 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"), @@ -112,7 +139,8 @@ func TestValidateCredentials(t *testing.T) { expectedSessionId: "kubefirst-session-user-123", }, { - name: "failed to get caller identity", + 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") @@ -125,7 +153,8 @@ func TestValidateCredentials(t *testing.T) { wantErr: true, }, { - name: "failed to assume role", + name: "failed to assume role", + 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{ @@ -143,7 +172,15 @@ func TestValidateCredentials(t *testing.T) { wantErr: true, }, { - name: "role does not have create cluster permission", + 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 + }, + }, simulator: simulator{ actionName: "eks:CreateCluster", decision: "explicitDeny", @@ -151,12 +188,62 @@ func TestValidateCredentials(t *testing.T) { wantErr: true, }, { - name: "simulator verification failed", + name: "simulator verification failed", + 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 + }, + }, simulator: simulator{ err: errors.New("failed to simulate"), }, 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 + }, + }, + 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 + }, + }, + simulator: simulator{ + actionName: "eks:CreateCluster", + decision: "allowed", + }, + expectedUserId: "user-123", + expectedSessionId: "kubefirst-session-user-123", + }, } for _, tt := range tests { @@ -167,7 +254,7 @@ func TestValidateCredentials(t *testing.T) { }, } - credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, checker, roleArn) + credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, tt.mockIamClient, checker, tt.roleARN) if tt.wantErr { require.Error(t, err) } else { From 50d87d35b1685b6052540a49a1a5567c0ff19e2e Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:46:33 -0500 Subject: [PATCH 08/25] Fix evaluation engine. --- internal/aws/aws.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/internal/aws/aws.go b/internal/aws/aws.go index c429f9a5d..6c5d456b2 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -44,13 +44,20 @@ func (a *Checker) CanRoleDoAction(ctx context.Context, roleArn string, actions [ return false, fmt.Errorf("policy simulation error: %w", err) } - // Evaluate simulation results + // Track allowed actions + allowedActions := make(map[string]bool) + + // Evaluate simulation results for each action for _, res := range simulateOutput.EvaluationResults { - if res.EvalActionName != nil && *res.EvalActionName == "eks:CreateCluster" { - // res.EvalDecision is one of: allowed / explicitDeny / implicitDeny - if res.EvalDecision == "allowed" { - return true, nil - } + 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 } } From 5730a80b876a51ea6276d5a2327f92ee1af248d9 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:55:20 -0500 Subject: [PATCH 09/25] Fix tests. Update code to return true if all pass. --- internal/aws/aws.go | 2 +- internal/aws/aws_test.go | 96 +++++++++++++++++++++++----------------- 2 files changed, 56 insertions(+), 42 deletions(-) diff --git a/internal/aws/aws.go b/internal/aws/aws.go index 6c5d456b2..7242a82ca 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -61,5 +61,5 @@ func (a *Checker) CanRoleDoAction(ctx context.Context, roleArn string, actions [ } } - return false, nil + return true, nil } diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go index db9ea6a4e..8dbf49697 100644 --- a/internal/aws/aws_test.go +++ b/internal/aws/aws_test.go @@ -22,71 +22,85 @@ func (m *mockIamClient) SimulatePrincipalPolicy(ctx context.Context, params *iam return nil, errors.New("not implemented") } -func TestCheckIfRoleCan(t *testing.T) { - ctx := context.Background() - roleArn := "arn:aws:iam::123456789012:role/MyRole" - actions := []string{"eks:CreateCluster"} - +func TestCanRoleDoAction(t *testing.T) { tests := []struct { name string - mockIamClient *mockIamClient + mockFn func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) + roleArn string + actions []string expectedResult bool - wantErr bool + expectedError error }{ { - name: "successful permission check", - mockIamClient: &mockIamClient{ - FnSimulatePrincipalPolicy: 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: types.PolicyEvaluationDecisionTypeAllowed, - }, + 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 - }, + }, + }, nil }, + roleArn: "arn:aws:iam::123456789012:role/MyRole", + actions: []string{"eks:CreateCluster", "s3:PutObject"}, expectedResult: true, + expectedError: nil, }, { - name: "permission denied", - mockIamClient: &mockIamClient{ - FnSimulatePrincipalPolicy: 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: types.PolicyEvaluationDecisionTypeExplicitDeny, - }, + 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 - }, + }, + }, nil }, + roleArn: "arn:aws:iam::123456789012:role/MyRole", + actions: []string{"eks:CreateCluster", "s3:PutObject"}, expectedResult: false, + expectedError: nil, }, { - name: "error from SimulatePrincipalPolicy", - mockIamClient: &mockIamClient{ - FnSimulatePrincipalPolicy: func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { - return nil, errors.New("simulate policy error") - }, + 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, - wantErr: true, + expectedError: errors.New("policy simulation error: simulation failed"), }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checker := &Checker{IAMClient: tt.mockIamClient} - result, err := checker.CanRoleDoAction(ctx, roleArn, actions) - if tt.wantErr { + 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, tt.expectedResult, result) } + + require.Equal(t, test.expectedResult, result) }) } } From 03ce55bc89d40659f98bc5100f5a27a3f0a08670 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:58:49 -0500 Subject: [PATCH 10/25] Add additional test for missing action. --- internal/aws/aws_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go index 8dbf49697..e481fb614 100644 --- a/internal/aws/aws_test.go +++ b/internal/aws/aws_test.go @@ -52,6 +52,27 @@ func TestCanRoleDoAction(t *testing.T) { 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) { From fb62a95c3e60095028511dc8010ea39ee2e4487e Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Wed, 15 Jan 2025 18:41:24 -0500 Subject: [PATCH 11/25] Update tests to comply with the new flow. --- cmd/aws/create.go | 88 +++++++++++++++++++++++---------- cmd/aws/create_test.go | 109 ++++++++++++++++++++++++----------------- 2 files changed, 126 insertions(+), 71 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 4c52c1b95..78c43a8bd 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -189,6 +189,19 @@ const ( sessionDuration = int32(21600) // We want at least 6 hours (21,600 seconds) ) +var wantedPermissions = []string{ + "eks:CreateCluster", + "eks:DescribeCluster", + // "cloudformation:CreateStack", + // "cloudformation:DescribeStacks", + "ec2:CreateSecurityGroup", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:DescribeVpcs", + "ec2:DescribeSubnets", + "ec2:DescribeSecurityGroups", + "iam:PassRole", +} + 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) @@ -196,6 +209,7 @@ type stsClienter interface { // 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) } @@ -217,12 +231,13 @@ func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamC } // Check if the currently provided role can perform EKS cluster creation - canCreateCluster, err := checker.CanRoleDoAction(ctx, roleArn, []string{"eks:CreateCluster"}) + // 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", roleArn) + return nil, fmt.Errorf("role %q does not have permission to create EKS clusters; required permissions: %s", roleArn, wantedPermissions) } // Create a session name (some unique identifier) @@ -246,27 +261,12 @@ func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamC // 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/AmazonEKSServicePolicy", - // "arn:aws:iam::aws:policy/AmazonEKSVPCResourceController", // "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess", } -type trustPolicyPrincipal struct { - AWS string -} - -type trustPolicyStatement struct { - Effect string - Principal trustPolicyPrincipal - Action string -} - -type trustPolicy struct { - Version string - Statement []trustPolicyStatement -} - func createKubernetesAdminRole(ctx context.Context, iamClient iamClienter, checker *internalaws.Checker, callerIdentity *sts.GetCallerIdentityOutput) (string, error) { // Verify that the current caller has permission to create IAM roles canCreateRole, err := checker.CanRoleDoAction(ctx, aws.ToString(callerIdentity.Arn), []string{"iam:CreateRole"}) @@ -277,18 +277,45 @@ func createKubernetesAdminRole(ctx context.Context, iamClient iamClienter, check return "", fmt.Errorf("caller %q does not have permission to create IAM roles", aws.ToString(callerIdentity.Arn)) } + // 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) + } + + // Create the policy in IAM + policyName := "KubefirstKubernetesAdminPolicy" + 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 := trustPolicy{ - Version: "2012-10-17", - Statement: []trustPolicyStatement{{ - Effect: "Allow", - Principal: trustPolicyPrincipal{ + 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), + "AWS": aws.ToString(callerIdentity.Arn), }, - Action: "sts:AssumeRole", + "Action": "sts:AssumeRole", }}, } @@ -320,6 +347,15 @@ func createKubernetesAdminRole(ctx context.Context, iamClient iamClienter, check 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 "", 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{ diff --git a/cmd/aws/create_test.go b/cmd/aws/create_test.go index 6d9b850d2..a32e1bc4d 100644 --- a/cmd/aws/create_test.go +++ b/cmd/aws/create_test.go @@ -54,6 +54,7 @@ func (m *mockAWSSimulator) SimulatePrincipalPolicy(ctx context.Context, params * 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) } func (m *mockIamClient) CreateRole(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) { @@ -72,25 +73,16 @@ func (m *mockIamClient) AttachRolePolicy(ctx context.Context, params *iam.Attach return nil, errors.New("not implemented") } -func TestValidateCredentials(t *testing.T) { - ctx := context.Background() +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...) + } - fnGenerateSimulator := func(actionName, decision string, err error) func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { - return func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { - if err != nil { - return nil, err - } + return nil, errors.New("not implemented") +} - return &iam.SimulatePrincipalPolicyOutput{ - EvaluationResults: []iamTypes.EvaluationResult{ - { - EvalActionName: aws.String(actionName), - EvalDecision: iamTypes.PolicyEvaluationDecisionType(decision), - }, - }, - }, nil - } - } +func TestValidateCredentials(t *testing.T) { + ctx := context.Background() type simulator struct { actionName string @@ -98,12 +90,47 @@ func TestValidateCredentials(t *testing.T) { err error } + allowedEksSimulation := []simulator{} + for _, v := range wantedPermissions { + allowedEksSimulation = append(allowedEksSimulation, simulator{ + actionName: v, + decision: "allowed", + }) + } + + fnGenerateSimulator := func(simulations []simulator) func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + output := &iam.SimulatePrincipalPolicyOutput{ + EvaluationResults: []iamTypes.EvaluationResult{}, + } + + var firstError error + + for _, s := range simulations { + output.EvaluationResults = append(output.EvaluationResults, iamTypes.EvaluationResult{ + EvalActionName: aws.String(s.actionName), + EvalDecision: iamTypes.PolicyEvaluationDecisionType(s.decision), + }) + + if s.err != nil && firstError == nil { + firstError = s.err + } + } + + return func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + if firstError != nil { + return nil, firstError + } + + return output, nil + } + } + tests := []struct { name string roleARN string mockStsClient *mockStsClient mockIamClient *mockIamClient // only required if roleARN is empty - simulator simulator + simulator []simulator wantErr bool expectedUserId string expectedSessionId string @@ -131,10 +158,7 @@ func TestValidateCredentials(t *testing.T) { }, nil }, }, - simulator: simulator{ - actionName: "eks:CreateCluster", - decision: "allowed", - }, + simulator: allowedEksSimulation, expectedUserId: "user-123", expectedSessionId: "kubefirst-session-user-123", }, @@ -146,11 +170,8 @@ func TestValidateCredentials(t *testing.T) { return nil, errors.New("failed to get caller identity") }, }, - simulator: simulator{ - actionName: "eks:CreateCluster", - decision: "allowed", - }, - wantErr: true, + simulator: allowedEksSimulation, + wantErr: true, }, { name: "failed to assume role", @@ -165,11 +186,8 @@ func TestValidateCredentials(t *testing.T) { return nil, errors.New("failed to assume role") }, }, - simulator: simulator{ - actionName: "eks:CreateCluster", - decision: "allowed", - }, - wantErr: true, + simulator: allowedEksSimulation, + wantErr: true, }, { name: "role does not have create cluster permission", @@ -181,10 +199,10 @@ func TestValidateCredentials(t *testing.T) { }, nil }, }, - simulator: simulator{ + simulator: []simulator{{ actionName: "eks:CreateCluster", decision: "explicitDeny", - }, + }}, wantErr: true, }, { @@ -197,9 +215,9 @@ func TestValidateCredentials(t *testing.T) { }, nil }, }, - simulator: simulator{ + simulator: []simulator{{ err: errors.New("failed to simulate"), - }, + }}, wantErr: true, }, { @@ -216,11 +234,19 @@ func TestValidateCredentials(t *testing.T) { 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"), + Arn: aws.String("arn:aws:iam::123456789012:user/example-user"), }, nil }, FnAssumeRole: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { @@ -237,10 +263,7 @@ func TestValidateCredentials(t *testing.T) { }, nil }, }, - simulator: simulator{ - actionName: "eks:CreateCluster", - decision: "allowed", - }, + simulator: append(allowedEksSimulation, simulator{actionName: "iam:CreateRole", decision: "allowed"}), expectedUserId: "user-123", expectedSessionId: "kubefirst-session-user-123", }, @@ -248,11 +271,7 @@ func TestValidateCredentials(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - checker := &internalaws.Checker{ - IAMClient: &mockAWSSimulator{ - FnSimulatePrincipalPolicy: fnGenerateSimulator(tt.simulator.actionName, tt.simulator.decision, tt.simulator.err), - }, - } + checker := &internalaws.Checker{IAMClient: &mockAWSSimulator{FnSimulatePrincipalPolicy: fnGenerateSimulator(tt.simulator)}} credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, tt.mockIamClient, checker, tt.roleARN) if tt.wantErr { From d0b5ac1079d387f101470c0c0c6ce6a9e41c441e Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:43:53 -0500 Subject: [PATCH 12/25] Pre-checks before operating. --- cmd/aws/create.go | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 78c43a8bd..9a1ab2120 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -71,7 +71,7 @@ func createAws(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to perform aws checks: %w", err) } - creds, err := convertLocalCredsToSession(ctx, stsClient, iamClient, checker, cliFlags.KubeAdminRoleARN) + 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) @@ -212,9 +212,11 @@ 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) } -func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamClient iamClienter, checker *internalaws.Checker, roleArn string) (*types.Credentials, error) { +func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamClient iamClienter, checker *internalaws.Checker, roleArn, clusterName string) (*types.Credentials, error) { // Check who we are currently (to ensure you're properly authenticated) callerIdentity, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) if err != nil { @@ -223,7 +225,7 @@ func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamC // If ARN is empty, create a new role with kubernetes admin permissions if roleArn == "" { - createdArn, err := createKubernetesAdminRole(ctx, iamClient, checker, callerIdentity) + 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) } @@ -267,14 +269,15 @@ var AdditionalRolePolicies = []string{ // "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess", } -func createKubernetesAdminRole(ctx context.Context, iamClient iamClienter, checker *internalaws.Checker, callerIdentity *sts.GetCallerIdentityOutput) (string, error) { +func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClient iamClienter, checker *internalaws.Checker, callerIdentity *sts.GetCallerIdentityOutput) (string, error) { // Verify that the current caller has permission to create IAM roles - canCreateRole, err := checker.CanRoleDoAction(ctx, aws.ToString(callerIdentity.Arn), []string{"iam:CreateRole"}) + 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 !canCreateRole { - return "", fmt.Errorf("caller %q does not have permission to create IAM roles", aws.ToString(callerIdentity.Arn)) + 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 @@ -293,8 +296,16 @@ func createKubernetesAdminRole(ctx context.Context, iamClient iamClienter, check 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 && cp.Policy != nil { + return "", fmt.Errorf("policy %q already exists: please delete the policy and try again", policyName) + } + // Create the policy in IAM - policyName := "KubefirstKubernetesAdminPolicy" cpo, err := iamClient.CreatePolicy(ctx, &iam.CreatePolicyInput{ PolicyName: aws.String(policyName), PolicyDocument: aws.String(string(permissionPolicyBytes)), @@ -325,8 +336,20 @@ func createKubernetesAdminRole(ctx context.Context, iamClient iamClienter, check 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 { + return "", fmt.Errorf("failed to get role %q: %w", roleName, err) + } + + if role.Role != nil { + return "", fmt.Errorf("role %q already exists: please delete the role and try again", roleName) + } + // Create the IAM role - roleName := "KubefirstKubernetesAdmin" createOut, err := iamClient.CreateRole(ctx, &iam.CreateRoleInput{ RoleName: aws.String(roleName), AssumeRolePolicyDocument: aws.String(string(trustPolicyBytes)), From b9011ea7da0ac7f61930e2b356e58eeda50c9ac9 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:46:31 -0500 Subject: [PATCH 13/25] Pre-checks before operating. --- cmd/aws/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 9a1ab2120..f549e4e7e 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -342,7 +342,7 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien // Check if a role with this name already exists role, err := iamClient.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(roleName)}) if err != nil { - return "", fmt.Errorf("failed to get role %q: %w", roleName, err) + return "", fmt.Errorf("failed to get role %q: %w %T %#v", roleName, err, err, err) } if role.Role != nil { From 52b9a25a8c12943577ed409dc06640c3263bc1b4 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Thu, 16 Jan 2025 14:18:00 -0500 Subject: [PATCH 14/25] Update with checks. --- cmd/aws/create.go | 20 ++++++++++++++++++-- cmd/aws/create_test.go | 3 ++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index f549e4e7e..3625fb078 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -11,11 +11,13 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "os" "slices" "strings" "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/config" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types" @@ -301,7 +303,16 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien // 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 && cp.Policy != nil { + if err != nil { + var newError *awshttp.ResponseError + if errors.As(err, newError) && newError.HTTPStatusCode() == http.StatusNotFound { + // Policy does not exist, continue + } else { + return "", fmt.Errorf("failed to get policy %q: %w", policyName, err) + } + } + + if cp.Policy != nil { return "", fmt.Errorf("policy %q already exists: please delete the policy and try again", policyName) } @@ -342,7 +353,12 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien // Check if a role with this name already exists role, err := iamClient.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(roleName)}) if err != nil { - return "", fmt.Errorf("failed to get role %q: %w %T %#v", roleName, err, err, err) + var newError *awshttp.ResponseError + if errors.As(err, newError) && newError.HTTPStatusCode() == http.StatusNotFound { + // Role does not exist, continue + } else { + return "", fmt.Errorf("failed to get role %q: %w", roleName, err) + } } if role.Role != nil { diff --git a/cmd/aws/create_test.go b/cmd/aws/create_test.go index a32e1bc4d..b82fb9170 100644 --- a/cmd/aws/create_test.go +++ b/cmd/aws/create_test.go @@ -271,9 +271,10 @@ func TestValidateCredentials(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + clusterName := "foobar" checker := &internalaws.Checker{IAMClient: &mockAWSSimulator{FnSimulatePrincipalPolicy: fnGenerateSimulator(tt.simulator)}} - credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, tt.mockIamClient, checker, tt.roleARN) + credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, tt.mockIamClient, checker, tt.roleARN, clusterName) if tt.wantErr { require.Error(t, err) } else { From 6d83d839eda4e3d87efbafde17fefebd4d433c53 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Thu, 16 Jan 2025 14:25:03 -0500 Subject: [PATCH 15/25] Update with checks. --- cmd/aws/create.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 3625fb078..a9710401d 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -304,8 +304,7 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien // 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 { + if newError := (&awshttp.ResponseError{}); errors.As(err, newError) && newError.HTTPStatusCode() == http.StatusNotFound { // Policy does not exist, continue } else { return "", fmt.Errorf("failed to get policy %q: %w", policyName, err) @@ -353,8 +352,7 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien // 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 { + if newError := (&awshttp.ResponseError{}); errors.As(err, newError) && newError.HTTPStatusCode() == http.StatusNotFound { // Role does not exist, continue } else { return "", fmt.Errorf("failed to get role %q: %w", roleName, err) From 4f2b84eae13f8682589beba8e82cb1cf8af1d498 Mon Sep 17 00:00:00 2001 From: Patrick D'appollonio <930925+patrickdappollonio@users.noreply.github.com> Date: Thu, 16 Jan 2025 14:27:17 -0500 Subject: [PATCH 16/25] Update with checks. --- cmd/aws/create.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index a9710401d..fb4b0557e 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -304,7 +304,8 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien // 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 { - if newError := (&awshttp.ResponseError{}); errors.As(err, newError) && newError.HTTPStatusCode() == http.StatusNotFound { + var newError awshttp.ResponseError + if errors.As(err, &newError) && newError.HTTPStatusCode() == http.StatusNotFound { // Policy does not exist, continue } else { return "", fmt.Errorf("failed to get policy %q: %w", policyName, err) @@ -352,7 +353,8 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien // Check if a role with this name already exists role, err := iamClient.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(roleName)}) if err != nil { - if newError := (&awshttp.ResponseError{}); errors.As(err, newError) && newError.HTTPStatusCode() == http.StatusNotFound { + var newError awshttp.ResponseError + if errors.As(err, &newError) && newError.HTTPStatusCode() == http.StatusNotFound { // Role does not exist, continue } else { return "", fmt.Errorf("failed to get role %q: %w", roleName, err) From f29a6bf696995000c65f94fffdbe8e27883b2c00 Mon Sep 17 00:00:00 2001 From: mrrishi Date: Fri, 17 Jan 2025 01:30:45 +0530 Subject: [PATCH 17/25] fix: errpr validation --- cmd/aws/create.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index fb4b0557e..73c478bce 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -251,7 +251,7 @@ func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamC output, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ RoleArn: aws.String(roleArn), RoleSessionName: aws.String(sessionName), - DurationSeconds: aws.Int32(sessionDuration), + // DurationSeconds: aws.Int32(sessionDuration), }) if err != nil { return nil, fmt.Errorf("failed to assume role %s: %w", roleArn, err) @@ -304,7 +304,7 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien // 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 + var newError *awshttp.ResponseError if errors.As(err, &newError) && newError.HTTPStatusCode() == http.StatusNotFound { // Policy does not exist, continue } else { @@ -312,7 +312,7 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien } } - if cp.Policy != nil { + if err == nil && cp.Policy != nil { return "", fmt.Errorf("policy %q already exists: please delete the policy and try again", policyName) } @@ -353,7 +353,7 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien // 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 + var newError *awshttp.ResponseError if errors.As(err, &newError) && newError.HTTPStatusCode() == http.StatusNotFound { // Role does not exist, continue } else { @@ -361,7 +361,7 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien } } - if role.Role != nil { + if err == nil && role.Role != nil { return "", fmt.Errorf("role %q already exists: please delete the role and try again", roleName) } From caf3e024d384f00b824fc8ca2cb2ab509ffc4a08 Mon Sep 17 00:00:00 2001 From: Muse Mulatu Date: Thu, 16 Jan 2025 13:43:44 -0700 Subject: [PATCH 18/25] check is user has permission to assume roles --- cmd/aws/create.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 73c478bce..dc9e0b793 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -218,7 +218,7 @@ type iamClienter interface { GetPolicy(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) } -func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamClient iamClienter, checker *internalaws.Checker, roleArn, clusterName string) (*types.Credentials, error) { +func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamClient *iam.Client, checker *internalaws.Checker, roleArn, clusterName string) (*types.Credentials, error) { // Check who we are currently (to ensure you're properly authenticated) callerIdentity, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) if err != nil { @@ -244,6 +244,16 @@ func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamC 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) From 07595e7ecc9b836cdf305ef9e71913dde0ca247e Mon Sep 17 00:00:00 2001 From: mrrishi Date: Mon, 27 Jan 2025 14:32:26 +0530 Subject: [PATCH 19/25] feat: add extra permissions to create a succesfull cluster --- cmd/aws/create.go | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index dc9e0b793..d440c83b4 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -15,6 +15,7 @@ import ( "os" "slices" "strings" + "time" "github.com/aws/aws-sdk-go-v2/aws" awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" @@ -192,16 +193,14 @@ const ( ) var wantedPermissions = []string{ - "eks:CreateCluster", - "eks:DescribeCluster", - // "cloudformation:CreateStack", - // "cloudformation:DescribeStacks", - "ec2:CreateSecurityGroup", - "ec2:AuthorizeSecurityGroupIngress", - "ec2:DescribeVpcs", - "ec2:DescribeSubnets", - "ec2:DescribeSecurityGroups", - "iam:PassRole", + "eks:*", + "ec2:*", + "s3:*", + "iam:*", + "dynamodb:*", + "kms:*", + "logs:*", + "application-autoscaling:*", } type stsClienter interface { @@ -257,6 +256,8 @@ func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamC // Create a session name (some unique identifier) sessionName := fmt.Sprintf("kubefirst-session-%s", *callerIdentity.UserId) + time.Sleep(5 * time.Second) + // Assume the role output, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ RoleArn: aws.String(roleArn), @@ -267,7 +268,7 @@ func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamC return nil, fmt.Errorf("failed to assume role %s: %w", roleArn, err) } - // Return the credentials + // // Return the credentials credentials := output.Credentials return credentials, nil } From 4f0adf7b7ae753bb500a143cb30b412f7b08734f Mon Sep 17 00:00:00 2001 From: mrrishi Date: Mon, 27 Jan 2025 19:53:18 +0530 Subject: [PATCH 20/25] fix: unit test cases --- cmd/aws/create.go | 42 +++++++++----- cmd/aws/create_test.go | 123 +++++++++++++++-------------------------- 2 files changed, 72 insertions(+), 93 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index d440c83b4..88504c5ba 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -74,17 +74,23 @@ func createAws(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to perform aws checks: %w", err) } - 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) - } + 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") - 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 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) + } } utilities.CreateK1ClusterDirectory(cliFlags.ClusterName) @@ -189,7 +195,7 @@ func ValidateProvidedFlags(ctx context.Context, cfg aws.Config, dnsProvider, git } const ( - sessionDuration = int32(21600) // We want at least 6 hours (21,600 seconds) + sessionDuration = int32(43200) // We want at least 6 hours (21,600 seconds) ) var wantedPermissions = []string{ @@ -217,7 +223,11 @@ type iamClienter interface { GetPolicy(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) } -func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamClient *iam.Client, checker *internalaws.Checker, roleArn, clusterName string) (*types.Credentials, 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) (*types.Credentials, error) { // Check who we are currently (to ensure you're properly authenticated) callerIdentity, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) if err != nil { @@ -262,7 +272,7 @@ func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamC output, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ RoleArn: aws.String(roleArn), RoleSessionName: aws.String(sessionName), - // DurationSeconds: aws.Int32(sessionDuration), + DurationSeconds: aws.Int32(sessionDuration), }) if err != nil { return nil, fmt.Errorf("failed to assume role %s: %w", roleArn, err) @@ -282,7 +292,11 @@ var AdditionalRolePolicies = []string{ // "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess", } -func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClient iamClienter, checker *internalaws.Checker, callerIdentity *sts.GetCallerIdentityOutput) (string, error) { +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) diff --git a/cmd/aws/create_test.go b/cmd/aws/create_test.go index b82fb9170..f23b63669 100644 --- a/cmd/aws/create_test.go +++ b/cmd/aws/create_test.go @@ -14,7 +14,6 @@ import ( ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/aws/aws-sdk-go-v2/service/sts/types" - internalaws "github.com/konstructio/kubefirst/internal/aws" "github.com/stretchr/testify/require" ) @@ -39,22 +38,12 @@ func (m *mockStsClient) AssumeRole(ctx context.Context, params *sts.AssumeRoleIn return nil, errors.New("not implemented") } -type mockAWSSimulator struct { - FnSimulatePrincipalPolicy func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) -} - -func (m *mockAWSSimulator) 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") -} - 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) { @@ -81,56 +70,42 @@ func (m *mockIamClient) CreatePolicy(ctx context.Context, params *iam.CreatePoli return nil, errors.New("not implemented") } -func TestValidateCredentials(t *testing.T) { - ctx := context.Background() - - type simulator struct { - actionName string - decision string - err error +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...) } - allowedEksSimulation := []simulator{} - for _, v := range wantedPermissions { - allowedEksSimulation = append(allowedEksSimulation, simulator{ - actionName: v, - decision: "allowed", - }) - } - - fnGenerateSimulator := func(simulations []simulator) func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { - output := &iam.SimulatePrincipalPolicyOutput{ - EvaluationResults: []iamTypes.EvaluationResult{}, - } - - var firstError error + return nil, errors.New("not implemented") +} - for _, s := range simulations { - output.EvaluationResults = append(output.EvaluationResults, iamTypes.EvaluationResult{ - EvalActionName: aws.String(s.actionName), - EvalDecision: iamTypes.PolicyEvaluationDecisionType(s.decision), - }) +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...) + } - if s.err != nil && firstError == nil { - firstError = s.err - } - } + return nil, errors.New("not implemented") +} - return func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { - if firstError != nil { - return nil, firstError - } +type mockChecker struct { + FnCanRoleDoAction func(ctx context.Context, roleArn string, actions []string) (bool, error) +} - return output, nil - } +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 roleARN string mockStsClient *mockStsClient mockIamClient *mockIamClient // only required if roleARN is empty - simulator []simulator + mockChecker *mockChecker wantErr bool expectedUserId string expectedSessionId string @@ -142,6 +117,7 @@ func TestValidateCredentials(t *testing.T) { 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) { @@ -158,7 +134,11 @@ func TestValidateCredentials(t *testing.T) { }, nil }, }, - simulator: allowedEksSimulation, + mockChecker: &mockChecker{ + FnCanRoleDoAction: func(ctx context.Context, roleArn string, actions []string) (bool, error) { + return true, nil + }, + }, expectedUserId: "user-123", expectedSessionId: "kubefirst-session-user-123", }, @@ -170,8 +150,7 @@ func TestValidateCredentials(t *testing.T) { return nil, errors.New("failed to get caller identity") }, }, - simulator: allowedEksSimulation, - wantErr: true, + wantErr: true, }, { name: "failed to assume role", @@ -180,33 +159,22 @@ func TestValidateCredentials(t *testing.T) { 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) { return nil, errors.New("failed to assume role") }, }, - simulator: allowedEksSimulation, - 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 true, nil }, }, - simulator: []simulator{{ - actionName: "eks:CreateCluster", - decision: "explicitDeny", - }}, wantErr: true, }, { - name: "simulator verification failed", + 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) { @@ -215,9 +183,11 @@ func TestValidateCredentials(t *testing.T) { }, nil }, }, - simulator: []simulator{{ - err: errors.New("failed to simulate"), - }}, + mockChecker: &mockChecker{ + FnCanRoleDoAction: func(ctx context.Context, roleArn string, actions []string) (bool, error) { + return false, nil + }, + }, wantErr: true, }, { @@ -246,7 +216,6 @@ func TestValidateCredentials(t *testing.T) { 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/example-user"), }, nil }, FnAssumeRole: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { @@ -263,18 +232,14 @@ func TestValidateCredentials(t *testing.T) { }, nil }, }, - simulator: append(allowedEksSimulation, simulator{actionName: "iam:CreateRole", decision: "allowed"}), - expectedUserId: "user-123", - expectedSessionId: "kubefirst-session-user-123", + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { clusterName := "foobar" - checker := &internalaws.Checker{IAMClient: &mockAWSSimulator{FnSimulatePrincipalPolicy: fnGenerateSimulator(tt.simulator)}} - - credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, tt.mockIamClient, checker, tt.roleARN, clusterName) + credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, tt.mockIamClient, tt.mockChecker, tt.roleARN, clusterName) if tt.wantErr { require.Error(t, err) } else { From 699f076cac0092ba3ce57b6573789d38f7693938 Mon Sep 17 00:00:00 2001 From: mrrishi Date: Mon, 27 Jan 2025 20:00:47 +0530 Subject: [PATCH 21/25] fix: lint issues --- cmd/aws/create.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 88504c5ba..38d2fb3e7 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -74,11 +74,11 @@ func createAws(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to perform aws checks: %w", err) } - accessKeyId := viper.Get("kubefirst.state-store-creds.access-key-id") + 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 { + 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()) @@ -330,9 +330,7 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien 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 { - // Policy does not exist, continue - } else { + if errors.As(err, &newError) && newError.HTTPStatusCode() != http.StatusNotFound { return "", fmt.Errorf("failed to get policy %q: %w", policyName, err) } } @@ -379,9 +377,7 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien 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 { - // Role does not exist, continue - } else { + if errors.As(err, &newError) && newError.HTTPStatusCode() != http.StatusNotFound { return "", fmt.Errorf("failed to get role %q: %w", roleName, err) } } From 88700c96cda1f4b0b19c1cfcb7698263661bf0f6 Mon Sep 17 00:00:00 2001 From: mrrishi Date: Mon, 27 Jan 2025 20:04:25 +0530 Subject: [PATCH 22/25] fix: lint issues --- cmd/aws/create.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 38d2fb3e7..fb89d76d3 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -75,10 +75,10 @@ func createAws(cmd *cobra.Command, _ []string) error { } accessKeyID := viper.Get("kubefirst.state-store-creds.access-key-id") - secretAccessKeyId := viper.Get("kubefirst.state-store-creds.secret-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 { + 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()) From 027a84e732208cf6ceefbf32bd815d8ae799aa02 Mon Sep 17 00:00:00 2001 From: mrrishi Date: Mon, 27 Jan 2025 20:21:12 +0530 Subject: [PATCH 23/25] fix: increase session duration --- cmd/aws/create.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index fb89d76d3..2cb41cdbf 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -390,6 +390,7 @@ func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClien 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 { From 6c2005b07570a282dd218adb8f6d056ff537e24d Mon Sep 17 00:00:00 2001 From: mrrishi Date: Wed, 29 Jan 2025 23:30:20 +0530 Subject: [PATCH 24/25] fix: add retries --- cmd/aws/create.go | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index 2cb41cdbf..e769e8505 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -41,6 +41,11 @@ import ( "github.com/spf13/viper" ) +const ( + maxRetries = 20 + baseDelay = 2 * time.Second +) + func createAws(cmd *cobra.Command, _ []string) error { cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderAWS) if err != nil { @@ -266,21 +271,13 @@ func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamC // Create a session name (some unique identifier) sessionName := fmt.Sprintf("kubefirst-session-%s", *callerIdentity.UserId) - time.Sleep(5 * time.Second) - - // Assume the role - output, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ - RoleArn: aws.String(roleArn), - RoleSessionName: aws.String(sessionName), - DurationSeconds: aws.Int32(sessionDuration), - }) + 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 - credentials := output.Credentials - return credentials, nil + return creds, nil } // AdditionalRolePolicies is a slice of policy ARNs you want to attach @@ -523,3 +520,24 @@ func getSupportedInstanceTypes(ctx context.Context, p paginator, architecture st } return instanceTypes, nil } + +func assumeRoleWithRetry(ctx context.Context, stsClient stsClienter, roleArn, sessionName string) (*types.Credentials, error) { + var lastErr error + for attempt := 1; attempt <= maxRetries; attempt++ { + output, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ + RoleArn: aws.String(roleArn), + RoleSessionName: aws.String(sessionName), + }) + if err == nil { + return output.Credentials, nil + } + + lastErr = err + + // Exponential backoff + delay := time.Duration(attempt*attempt) * baseDelay + time.Sleep(delay) + } + + return nil, fmt.Errorf("failed to assume role after %d attempts: %w", maxRetries, lastErr) +} From b95180a2b113b53c597977cceb761dd1efa760d0 Mon Sep 17 00:00:00 2001 From: mrrishi Date: Thu, 30 Jan 2025 00:45:06 +0530 Subject: [PATCH 25/25] fix: tests --- cmd/aws/create.go | 7 ++----- cmd/aws/create_test.go | 21 --------------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/cmd/aws/create.go b/cmd/aws/create.go index e769e8505..af5933299 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -522,22 +522,19 @@ func getSupportedInstanceTypes(ctx context.Context, p paginator, architecture st } func assumeRoleWithRetry(ctx context.Context, stsClient stsClienter, roleArn, sessionName string) (*types.Credentials, error) { - var lastErr 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 } - lastErr = err - - // Exponential backoff delay := time.Duration(attempt*attempt) * baseDelay time.Sleep(delay) } - return nil, fmt.Errorf("failed to assume role after %d attempts: %w", maxRetries, lastErr) + return nil, fmt.Errorf("failed to assume role") } diff --git a/cmd/aws/create_test.go b/cmd/aws/create_test.go index f23b63669..7dfe199d4 100644 --- a/cmd/aws/create_test.go +++ b/cmd/aws/create_test.go @@ -152,27 +152,6 @@ func TestValidateCredentials(t *testing.T) { }, wantErr: true, }, - { - name: "failed to assume role", - 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) { - return nil, errors.New("failed to assume role") - }, - }, - mockChecker: &mockChecker{ - FnCanRoleDoAction: func(ctx context.Context, roleArn string, actions []string) (bool, error) { - return true, nil - }, - }, - wantErr: true, - }, { name: "role does not have create cluster permission", roleARN: "arn:aws:iam::123456789012:role/example-role",