├── .github ├── dependabot.yml └── workflows │ └── pipeline.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── cmd ├── cluster.go ├── flag.go ├── get.go ├── list.go ├── root.go └── version.go ├── go.mod ├── go.sum ├── internal ├── aws │ ├── cert.go │ ├── cert_test.go │ ├── client.go │ ├── cluster.go │ ├── event.go │ ├── oidc.go │ └── role.go ├── errs │ └── errors.go ├── k8s │ ├── client.go │ ├── kubeconfig.go │ └── kubeconfig_test.go └── out │ └── table.go └── main.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | groups: 8 | go: 9 | patterns: 10 | - '*' 11 | 12 | - package-ecosystem: github-actions 13 | directory: /.github 14 | schedule: 15 | interval: daily 16 | groups: 17 | github-actions: 18 | patterns: 19 | - '*' 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: pipeline 2 | 3 | on: [push] 4 | 5 | permissions: 6 | contents: write 7 | 8 | jobs: 9 | go: 10 | uses: pete911/github-actions/.github/workflows/go.yml@main 11 | go-release: 12 | needs: 13 | - go 14 | uses: pete911/github-actions/.github/workflows/go-releaser.yml@main 15 | secrets: 16 | PUBLIC_REPO_TOKEN: ${{ secrets.PUBLIC_REPO_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /kubectl-iam4sa 2 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | goos: 5 | - linux 6 | - windows 7 | - darwin 8 | goarch: 9 | - amd64 10 | - arm64 11 | ldflags: 12 | - -X main.Version={{.Version}} 13 | 14 | archives: 15 | - format: tar.gz 16 | format_overrides: 17 | - goos: windows 18 | format: zip 19 | checksum: 20 | name_template: 'checksums.txt' 21 | snapshot: 22 | name_template: "{{ incpatch .Version }}-next" 23 | changelog: 24 | sort: asc 25 | filters: 26 | exclude: 27 | - '^docs:' 28 | - '^test:' 29 | release: 30 | github: 31 | owner: pete911 32 | name: kubectl-iam4sa 33 | brews: 34 | - repository: 35 | owner: pete911 36 | name: homebrew-tap 37 | token: "{{ .Env.GITHUB_TOKEN }}" 38 | name: kubectl-iam4sa 39 | homepage: "https://github.com/pete911/kubectl-iam4sa" 40 | description: "debug IAM roles for service accounts" 41 | folder: Formula 42 | install: | 43 | bin.install "kubectl-iam4sa" 44 | test: | 45 | assert_match /Usage/, shell_output("#{bin}/kubectl-iam4sa -h", 0) 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Peter Reisinger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kubectl-iam4sa 2 | Debug IAM roles for service accounts. User needs to have access to cluster, AWS IAM and CloudTrail API. 3 | 4 | ```shell 5 | Available Commands: 6 | cluster EKS cluster oidc information 7 | get get IAM service account 8 | help help about any command 9 | list list IAM service accounts 10 | version print version 11 | 12 | Flags: 13 | -A, --all-namespaces all kubernetes namespaces 14 | --field-selector string kubernetes field selector 15 | -h, --help help for this command 16 | --kubeconfig string path to kubeconfig file (default "~/.kube/config") 17 | -l, --label string kubernetes label 18 | --log-level string log level - debug, info, warn, error (default "warn") 19 | -n, --namespace string kubernetes namespace (default "default") 20 | ``` 21 | 22 | ## cluster information 23 | 24 | `kubectl-iam4sa cluster` 25 | 26 | ``` 27 | Name: main 28 | Status: ACTIVE 29 | Endpoint: https://123456789123.gr7.eu-west-2.eks.amazonaws.com 30 | Created: 2023-10-13T08:41:17Z 31 | OIDC Issuer: 32 | Url: https://oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123 33 | Thumbprint: 9e9e9e9e999999999eeeee9992e9999998888877 34 | OIDC Provider: 35 | Arn: arn:aws:iam::123456789123:oidc-provider/oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123 36 | Url: oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123 37 | Created: 2023-10-13T08:48:18Z 38 | Client Ids: 39 | sts.amazonaws.com 40 | Thumbprints: 41 | 9e9e9e9e999999999eeeee9992e9999998888877 42 | ``` 43 | 44 | Verify that the OIDC Provider is found and has matching url and thumbprint. 45 | 46 | ## list service accounts with IAM role 47 | 48 | `kubectl-iam4sa list -A` - list service accounts in all namespaces 49 | ``` 50 | NAMESPACE SERVICE ACCOUNT PODS IAM ROLE ACCOUNT IAM ROLE EVENTS FAILED 51 | default ebs-csi-controller-sa 2 123456789123 ebs-csi-controller 0 0 52 | karpenter karpenter 2 123456789123 karpenter-controller 15 0 53 | prometheus amp-iamproxy-ingest-service-account 1 123456789123 prometheus 40 25 54 | ``` 55 | List displays service accounts with `eks.amazonaws.com/role-arn` annotations, number of pods that use this service 56 | account. IAM Role account and name is from the service account annotation. Events is a number of events 57 | (from CloudTrail) in the past 12 hours for this service account. 58 | 59 | ## get service account 60 | 61 | `kubectl-iam4sa get -n ` 62 | ``` 63 | Name: amp-iamproxy-ingest-service-account 64 | Namespace: prometheus 65 | Pods: 66 | prometheus-server-abc-xyz 67 | 68 | Service Account Role: arn:aws:iam::123456789123:role/prometheus 69 | { 70 | "Statement": [ 71 | { 72 | "Action": "sts:AssumeRoleWithWebIdentity", 73 | "Condition": { 74 | "StringEquals": { 75 | "oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123:aud": "sts.amazonaws.com", 76 | "oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123:sub": "system:serviceaccount:prometheus:amp-iamproxy-ingest-service-account" 77 | } 78 | }, 79 | "Effect": "Allow", 80 | "Principal": { 81 | "Federated": "arn:aws:iam::123456789123:oidc-provider/oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123" 82 | }, 83 | "Sid": "" 84 | } 85 | ], 86 | "Version": "2012-10-17" 87 | } 88 | 89 | Failed Events: 90 | TIME CODE MESSAGE REQUEST ROLE ACTUAL ROLE 91 | 2023-11-23T15:35:48Z AccessDenied An unknown error occurred arn:aws:iam::123456789123:role/promethus-ingest arn:aws:iam::123456789123:role/prometheus 92 | 2023-11-23T15:19:08Z AccessDenied An unknown error occurred arn:aws:iam::123456789123:role/promethus-ingest arn:aws:iam::123456789123:role/prometheus 93 | ``` 94 | 95 | List more detailed information about service account(s) and IAM role(s). Verify principal and condition in the trust 96 | policy with output from `kubectl-iam4sa cluster` output. 97 | 98 | In the example above, we can see in the failed events, that the pod is requesting `prometheus-ingest` role, but the role 99 | that is set in annotation is `prometheus`. In this case most likely the pod needs to be restarted. 100 | 101 | ## download 102 | 103 | - [binary](https://github.com/pete911/kubectl-iam4sa/releases) 104 | 105 | ## build/install 106 | 107 | ### brew 108 | 109 | - add tap `brew tap pete911/tap` 110 | - install `brew install kubectl-iam4sa` 111 | 112 | ### go 113 | 114 | [go](https://golang.org/dl/) has to be installed. 115 | - build `go build` 116 | - install `go install` 117 | -------------------------------------------------------------------------------- /cmd/cluster.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/pete911/kubectl-iam4sa/internal/aws" 7 | "github.com/pete911/kubectl-iam4sa/internal/errs" 8 | "github.com/spf13/cobra" 9 | "log/slog" 10 | "os" 11 | "time" 12 | ) 13 | 14 | var ( 15 | cmdCluster = &cobra.Command{ 16 | Use: "cluster", 17 | Short: "EKS cluster oidc information", 18 | Long: "", 19 | Run: runClusterCmd, 20 | } 21 | ) 22 | 23 | func init() { 24 | RootCmd.AddCommand(cmdCluster) 25 | } 26 | 27 | func runClusterCmd(_ *cobra.Command, args []string) { 28 | logger := GlobalFlags.Logger() 29 | kubeconfig := GlobalFlags.Kubeconfig() 30 | 31 | logger.Debug(fmt.Sprintf("kubeconfig: %s", kubeconfig)) 32 | awsClient, err := aws.NewClient(logger, kubeconfig.Region, kubeconfig.ClusterName) 33 | if err != nil { 34 | fmt.Printf("aws client: %v\n", err) 35 | os.Exit(1) 36 | } 37 | 38 | cluster, err := awsClient.DescribeCluster() 39 | if err != nil { 40 | fmt.Printf("describe cluster: %v\n", err) 41 | os.Exit(1) 42 | } 43 | 44 | oidcProvider, err := awsClient.GetClusterOidcProvider(cluster.OidcIssuerId()) 45 | if err != nil { 46 | // continue if the error is not found, we want to display to the user that there's no oidc provider 47 | var errNotFound *errs.ErrNotFound 48 | if !errors.As(err, &errNotFound) { 49 | fmt.Printf("get cluster oidc provider: %v\n", err) 50 | os.Exit(1) 51 | } 52 | } 53 | printCluster(logger, cluster, oidcProvider) 54 | } 55 | 56 | func printCluster(logger *slog.Logger, cluster aws.Cluster, oidcProvider aws.OidcProvider) { 57 | fingerprint, err := cluster.OidcIssuerFingerprint() 58 | if err != nil { 59 | logger.Error(fmt.Sprintf("oidc cluster issuer fingerprint: %v", err)) 60 | } 61 | 62 | fmt.Printf("Name: %s\n", cluster.Name) 63 | fmt.Printf("Status: %s\n", cluster.Status) 64 | fmt.Printf("Endpoint: %s\n", cluster.Endpoint) 65 | fmt.Printf("Created: %s\n", cluster.CreatedAt.Format(time.RFC3339)) 66 | fmt.Println("OIDC Issuer:") 67 | fmt.Printf(" Url: %s\n", cluster.OidcIssuer) 68 | fmt.Printf(" Thumbprint: %s\n", fingerprint) 69 | if oidcProvider.Url == "" { 70 | fmt.Println("OIDC Provider: not found") 71 | return 72 | } 73 | fmt.Println("OIDC Provider:") 74 | fmt.Printf(" Arn: %s\n", oidcProvider.Arn) 75 | fmt.Printf(" Url: %s\n", oidcProvider.Url) 76 | fmt.Printf(" Created: %s\n", oidcProvider.CreateDate.Format(time.RFC3339)) 77 | fmt.Println(" Client Ids:") 78 | for _, id := range oidcProvider.ClientIDs { 79 | fmt.Printf(" %s\n", id) 80 | } 81 | fmt.Println(" Thumbprints:") 82 | for _, thumbprint := range oidcProvider.Thumbprints { 83 | fmt.Printf(" %s\n", thumbprint) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /cmd/flag.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pete911/kubectl-iam4sa/internal/k8s" 6 | "github.com/spf13/cobra" 7 | "k8s.io/client-go/util/homedir" 8 | "log/slog" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | var logLevels = map[string]slog.Level{"debug": slog.LevelDebug, "info": slog.LevelInfo, "warn": slog.LevelWarn, "error": slog.LevelError} 15 | 16 | type Flags struct { 17 | kubeconfigPath string 18 | logLevel string 19 | namespace string 20 | allNamespaces bool 21 | label string 22 | fieldSelector string 23 | } 24 | 25 | func (f Flags) Kubeconfig() k8s.Kubeconfig { 26 | kubeconfig, err := k8s.NewKubeconfig(f.kubeconfigPath) 27 | if err != nil { 28 | fmt.Printf("load kubeconfig %s: %v", f.kubeconfigPath, err) 29 | os.Exit(1) 30 | } 31 | return kubeconfig 32 | } 33 | 34 | func (f Flags) Logger() *slog.Logger { 35 | if level, ok := logLevels[strings.ToLower(f.logLevel)]; ok { 36 | opts := &slog.HandlerOptions{Level: level} 37 | return slog.New(slog.NewJSONHandler(os.Stderr, opts)) 38 | } 39 | 40 | fmt.Printf("invalid log level %s", f.logLevel) 41 | os.Exit(1) 42 | return nil 43 | } 44 | 45 | func (f Flags) Namespace() string { 46 | if f.allNamespaces { 47 | return "" 48 | } 49 | return f.namespace 50 | } 51 | 52 | func (f Flags) Label() string { 53 | return f.label 54 | } 55 | 56 | func (f Flags) FieldSelector(args []string) string { 57 | for _, v := range args { 58 | fieldSelectors := strings.Split(f.fieldSelector, ",") 59 | fieldSelectors = append(fieldSelectors, fmt.Sprintf("metadata.name=%s", v)) 60 | f.fieldSelector = strings.Join(fieldSelectors, ",") 61 | } 62 | return f.fieldSelector 63 | } 64 | 65 | func InitPersistentFlags(cmd *cobra.Command, flags *Flags) { 66 | defaultKubeconfig := filepath.Join(homedir.HomeDir(), ".kube", "config") 67 | cmd.PersistentFlags().StringVar( 68 | &flags.kubeconfigPath, 69 | "kubeconfig", 70 | getStringEnv("KUBECONFIG", defaultKubeconfig), 71 | "path to kubeconfig file", 72 | ) 73 | cmd.PersistentFlags().StringVar( 74 | &flags.logLevel, 75 | "log-level", 76 | "warn", 77 | "log level - debug, info, warn, error", 78 | ) 79 | cmd.PersistentFlags().StringVarP( 80 | &flags.namespace, 81 | "namespace", 82 | "n", 83 | "default", 84 | "kubernetes namespace", 85 | ) 86 | cmd.PersistentFlags().BoolVarP( 87 | &flags.allNamespaces, 88 | "all-namespaces", 89 | "A", 90 | false, 91 | "all kubernetes namespaces", 92 | ) 93 | cmd.PersistentFlags().StringVarP( 94 | &flags.label, 95 | "label", 96 | "l", 97 | "", 98 | "kubernetes label", 99 | ) 100 | cmd.PersistentFlags().StringVarP( 101 | &flags.fieldSelector, 102 | "field-selector", 103 | "", 104 | "", 105 | "kubernetes field selector", 106 | ) 107 | } 108 | 109 | func getStringEnv(envName string, defaultValue string) string { 110 | if env, ok := os.LookupEnv(envName); ok { 111 | return env 112 | } 113 | return defaultValue 114 | } 115 | -------------------------------------------------------------------------------- /cmd/get.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/pete911/kubectl-iam4sa/internal/aws" 7 | "github.com/pete911/kubectl-iam4sa/internal/k8s" 8 | "github.com/pete911/kubectl-iam4sa/internal/out" 9 | "github.com/spf13/cobra" 10 | "log/slog" 11 | "os" 12 | "time" 13 | ) 14 | 15 | var ( 16 | cmdGet = &cobra.Command{ 17 | Use: "get", 18 | Short: "get IAM service account", 19 | Long: "", 20 | Run: runGetCmd, 21 | } 22 | ) 23 | 24 | func init() { 25 | RootCmd.AddCommand(cmdGet) 26 | } 27 | 28 | func runGetCmd(_ *cobra.Command, args []string) { 29 | logger := GlobalFlags.Logger() 30 | kubeconfig := GlobalFlags.Kubeconfig() 31 | 32 | k8sClient, err := k8s.NewClient(logger, kubeconfig) 33 | if err != nil { 34 | fmt.Printf("k8s client: %v\n", err) 35 | os.Exit(1) 36 | } 37 | 38 | logger.Debug(fmt.Sprintf("kubeconfig: %s", kubeconfig)) 39 | awsClient, err := aws.NewClient(logger, kubeconfig.Region, kubeconfig.ClusterName) 40 | if err != nil { 41 | fmt.Printf("aws client: %v\n", err) 42 | os.Exit(1) 43 | } 44 | 45 | fieldSelector := GlobalFlags.FieldSelector(args) 46 | sas, err := k8sClient.ListIAMServiceAccounts(GlobalFlags.Namespace(), GlobalFlags.Label(), fieldSelector) 47 | if err != nil { 48 | fmt.Printf("get IAM service accounts: %v\n", err) 49 | os.Exit(1) 50 | } 51 | printGet(logger, awsClient, sas) 52 | } 53 | 54 | func printGet(logger *slog.Logger, awsClient aws.Client, sas []k8s.ServiceAccount) { 55 | for _, sa := range sas { 56 | printGetSa(logger, awsClient, sa) 57 | } 58 | } 59 | 60 | func printGetSa(logger *slog.Logger, awsClient aws.Client, sa k8s.ServiceAccount) { 61 | role, err := awsClient.GetIAMRole(sa.RoleName()) 62 | if err != nil { 63 | logger.Error(fmt.Sprintf("get role for %s/%s service account: %v", sa.Namespace, sa.Name, err)) 64 | } 65 | 66 | events, err := awsClient.LookupEvents(sa.Namespace, sa.Name) 67 | if err != nil { 68 | logger.Error(fmt.Sprintf("lookup %s/%s event: %v", sa.Namespace, sa.Name, err)) 69 | } 70 | failedEvents := events.FailedEvents() 71 | 72 | printSA(sa) 73 | 74 | fmt.Println() 75 | printRole(logger, sa, role) 76 | 77 | // if there are any failed events, lets print them 78 | if len(failedEvents) != 0 { 79 | fmt.Println() 80 | fmt.Println("Failed Events:") 81 | printEvents(logger, sa, failedEvents) 82 | } 83 | } 84 | 85 | func printSA(sa k8s.ServiceAccount) { 86 | fmt.Printf("Name: %s\n", sa.Name) 87 | fmt.Printf("Namespace: %s\n", sa.Namespace) 88 | fmt.Println("Pods:") 89 | for _, pod := range sa.Pods { 90 | fmt.Printf(" %s\n", pod) 91 | } 92 | } 93 | 94 | func printRole(logger *slog.Logger, sa k8s.ServiceAccount, role aws.Role) { 95 | fmt.Printf("Service Account Role: %s\n", sa.IamRoleArn) 96 | if role.ARN == "" { 97 | fmt.Println("AWS Role Policy Document: not found") 98 | return 99 | } 100 | jsonPrettyPrint(logger, role.AssumeRolePolicyDocument) 101 | } 102 | 103 | func printEvents(logger *slog.Logger, sa k8s.ServiceAccount, events []aws.Event) { 104 | table := out.NewTable(logger) 105 | table.AddRow("TIME", "CODE", "MESSAGE", "REQUEST ROLE", "SA ROLE") 106 | for i, event := range events { 107 | // print max last 5 failed events 108 | if i == 5 { 109 | break 110 | } 111 | table.AddRow(event.EventTime.Format(time.RFC3339), event.ErrorCode, event.ErrorMessage, event.RequestParameters.RoleArn, sa.IamRoleArn) 112 | } 113 | table.Print() 114 | } 115 | 116 | func jsonPrettyPrint(logger *slog.Logger, in string) { 117 | var inJson any 118 | if err := json.Unmarshal([]byte(in), &inJson); err != nil { 119 | logger.Error(err.Error()) 120 | return 121 | } 122 | b, err := json.MarshalIndent(inJson, "", " ") 123 | if err != nil { 124 | logger.Error(err.Error()) 125 | return 126 | } 127 | fmt.Println(string(b)) 128 | } 129 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pete911/kubectl-iam4sa/internal/aws" 6 | "github.com/pete911/kubectl-iam4sa/internal/k8s" 7 | "github.com/pete911/kubectl-iam4sa/internal/out" 8 | "github.com/spf13/cobra" 9 | "log/slog" 10 | "os" 11 | ) 12 | 13 | var ( 14 | cmdList = &cobra.Command{ 15 | Use: "list", 16 | Short: "list IAM service accounts", 17 | Long: "", 18 | Run: runListCmd, 19 | } 20 | ) 21 | 22 | func init() { 23 | RootCmd.AddCommand(cmdList) 24 | } 25 | 26 | func runListCmd(_ *cobra.Command, args []string) { 27 | logger := GlobalFlags.Logger() 28 | kubeconfig := GlobalFlags.Kubeconfig() 29 | 30 | k8sClient, err := k8s.NewClient(logger, kubeconfig) 31 | if err != nil { 32 | fmt.Printf("k8s client: %v\n", err) 33 | os.Exit(1) 34 | } 35 | 36 | logger.Debug(fmt.Sprintf("kubeconfig: %s", kubeconfig)) 37 | awsClient, err := aws.NewClient(logger, kubeconfig.Region, kubeconfig.ClusterName) 38 | if err != nil { 39 | fmt.Printf("aws client: %v\n", err) 40 | os.Exit(1) 41 | } 42 | 43 | fieldSelector := GlobalFlags.FieldSelector(args) 44 | sas, err := k8sClient.ListIAMServiceAccounts(GlobalFlags.Namespace(), GlobalFlags.Label(), fieldSelector) 45 | if err != nil { 46 | fmt.Printf("list IAM service accounts: %v\n", err) 47 | os.Exit(1) 48 | } 49 | printListTable(logger, awsClient, sas) 50 | } 51 | 52 | func printListTable(logger *slog.Logger, awsClient aws.Client, sas []k8s.ServiceAccount) { 53 | table := out.NewTable(logger) 54 | table.AddRow("NAMESPACE", "SERVICE ACCOUNT", "PODS", "IAM ROLE ACCOUNT", "IAM ROLE", "EVENTS", "FAILED") 55 | for _, sa := range sas { 56 | events, err := awsClient.LookupEvents(sa.Namespace, sa.Name) 57 | if err != nil { 58 | logger.Error(fmt.Sprintf("lookup %s/%s event: %v", sa.Namespace, sa.Name, err)) 59 | } 60 | 61 | numPods := fmt.Sprintf("%d", len(sa.Pods)) 62 | numEvents := fmt.Sprintf("%d", len(events)) 63 | numFailedEvents := fmt.Sprintf("%d", len(events.FailedEvents())) 64 | table.AddRow(sa.Namespace, sa.Name, numPods, sa.RoleAccount(), sa.RoleName(), numEvents, numFailedEvents) 65 | } 66 | table.Print() 67 | } 68 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var ( 8 | RootCmd = &cobra.Command{} 9 | 10 | Version string 11 | GlobalFlags Flags 12 | ) 13 | 14 | func init() { 15 | InitPersistentFlags(RootCmd, &GlobalFlags) 16 | } 17 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var ( 9 | cmdVersion = &cobra.Command{ 10 | Use: "version", 11 | Short: "print version", 12 | Long: "", 13 | Run: runVersionCmd, 14 | } 15 | ) 16 | 17 | func init() { 18 | RootCmd.AddCommand(cmdVersion) 19 | } 20 | 21 | func runVersionCmd(_ *cobra.Command, _ []string) { 22 | fmt.Println(Version) 23 | } 24 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pete911/kubectl-iam4sa 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/aws/aws-sdk-go-v2 v1.36.3 9 | github.com/aws/aws-sdk-go-v2/config v1.29.14 10 | github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.49.0 11 | github.com/aws/aws-sdk-go-v2/service/eks v1.64.0 12 | github.com/aws/aws-sdk-go-v2/service/iam v1.42.0 13 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 14 | github.com/spf13/cobra v1.9.1 15 | github.com/stretchr/testify v1.10.0 16 | k8s.io/apimachinery v0.33.1 17 | k8s.io/client-go v0.33.1 18 | ) 19 | 20 | require ( 21 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect 22 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect 23 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect 24 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect 25 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect 27 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect 28 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect 29 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect 30 | github.com/aws/smithy-go v1.22.2 // indirect 31 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 32 | github.com/emicklei/go-restful/v3 v3.12.1 // indirect 33 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 34 | github.com/go-logr/logr v1.4.2 // indirect 35 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 36 | github.com/go-openapi/jsonreference v0.21.0 // indirect 37 | github.com/go-openapi/swag v0.23.0 // indirect 38 | github.com/gogo/protobuf v1.3.2 // indirect 39 | github.com/google/gnostic-models v0.6.9 // indirect 40 | github.com/google/go-cmp v0.7.0 // indirect 41 | github.com/google/uuid v1.6.0 // indirect 42 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 43 | github.com/josharian/intern v1.0.0 // indirect 44 | github.com/json-iterator/go v1.1.12 // indirect 45 | github.com/mailru/easyjson v0.9.0 // indirect 46 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 47 | github.com/modern-go/reflect2 v1.0.2 // indirect 48 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 49 | github.com/pkg/errors v0.9.1 // indirect 50 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 51 | github.com/spf13/pflag v1.0.6 // indirect 52 | github.com/x448/float16 v0.8.4 // indirect 53 | golang.org/x/net v0.38.0 // indirect 54 | golang.org/x/oauth2 v0.27.0 // indirect 55 | golang.org/x/sys v0.31.0 // indirect 56 | golang.org/x/term v0.30.0 // indirect 57 | golang.org/x/text v0.23.0 // indirect 58 | golang.org/x/time v0.9.0 // indirect 59 | google.golang.org/protobuf v1.36.5 // indirect 60 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 61 | gopkg.in/inf.v0 v0.9.1 // indirect 62 | gopkg.in/yaml.v3 v3.0.1 // indirect 63 | k8s.io/api v0.33.1 // indirect 64 | k8s.io/klog/v2 v2.130.1 // indirect 65 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 66 | k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect 67 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 68 | sigs.k8s.io/randfill v1.0.0 // indirect 69 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 70 | sigs.k8s.io/yaml v1.4.0 // indirect 71 | ) 72 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= 2 | github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= 3 | github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= 4 | github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= 5 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= 6 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= 7 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= 8 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= 9 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= 10 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= 11 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= 12 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= 13 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= 14 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= 15 | github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.49.0 h1:RaAAMoGAns9TPioFYyvZBvMnNjw4fZCoAlud3MEWHv8= 16 | github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.49.0/go.mod h1:/BibEr5ksr34abqBTQN213GrNG6GCKCB6WG7CH4zH2w= 17 | github.com/aws/aws-sdk-go-v2/service/eks v1.64.0 h1:EYeOThTRysemFtC6J6h6b7dNg3jN03QuO5cg92ojIQE= 18 | github.com/aws/aws-sdk-go-v2/service/eks v1.64.0/go.mod h1:v1xXy6ea0PHtWkjFUvAUh6B/5wv7UF909Nru0dOIJDk= 19 | github.com/aws/aws-sdk-go-v2/service/iam v1.42.0 h1:G6+UzGvubaet9QOh0664E9JeT+b6Zvop3AChozRqkrA= 20 | github.com/aws/aws-sdk-go-v2/service/iam v1.42.0/go.mod h1:mPJkGQzeCoPs82ElNILor2JzZgYENr4UaSKUT8K27+c= 21 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= 22 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= 23 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= 24 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= 25 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= 26 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= 27 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= 28 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= 29 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= 30 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= 31 | github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= 32 | github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 33 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 34 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 35 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 36 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 37 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 38 | github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= 39 | github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 40 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 41 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 42 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 43 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 44 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 45 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 46 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 47 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 48 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 49 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 50 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 51 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 52 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 53 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 54 | github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= 55 | github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= 56 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 57 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 58 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 59 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 60 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= 61 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 62 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 63 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 64 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 65 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 66 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 67 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 68 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 69 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 70 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 71 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 72 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 73 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 74 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 75 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 76 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 77 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 78 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 79 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 80 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 81 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 82 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 83 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 84 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 85 | github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= 86 | github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= 87 | github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= 88 | github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= 89 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 90 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 91 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 92 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 93 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 94 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 95 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 96 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 97 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 98 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 99 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 100 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 101 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 102 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 103 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 104 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 105 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 106 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 107 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 108 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 109 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 110 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 111 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 112 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 113 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 114 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 115 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 116 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 117 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 118 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 119 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 120 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 121 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 122 | golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 123 | golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 124 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 125 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 126 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 127 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 128 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 129 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 130 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 131 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 132 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 133 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 134 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 135 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 136 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 137 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 138 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 139 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 140 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 141 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 142 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 143 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 144 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 145 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 146 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 147 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 148 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 149 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 150 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 151 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 152 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 153 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 154 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 155 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 156 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 157 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 158 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 159 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 160 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 161 | k8s.io/api v0.33.1 h1:tA6Cf3bHnLIrUK4IqEgb2v++/GYUtqiu9sRVk3iBXyw= 162 | k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw= 163 | k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4= 164 | k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= 165 | k8s.io/client-go v0.33.1 h1:ZZV/Ks2g92cyxWkRRnfUDsnhNn28eFpt26aGc8KbXF4= 166 | k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA= 167 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 168 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 169 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= 170 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= 171 | k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= 172 | k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 173 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= 174 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 175 | sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 176 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 177 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 178 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= 179 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= 180 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 181 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 182 | -------------------------------------------------------------------------------- /internal/aws/cert.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "crypto/sha1" 5 | "crypto/tls" 6 | "fmt" 7 | "net" 8 | "net/url" 9 | "time" 10 | ) 11 | 12 | // FingerprintSHA1 returns certificate sha1 fingerprint e.g. oidc.eks.eu-west-2.amazonaws.com 13 | func FingerprintSHA1(addr string, tlsSkipVerify bool) (string, error) { 14 | hostAndPort, err := getHostAndPort(addr) 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", hostAndPort, &tls.Config{InsecureSkipVerify: tlsSkipVerify}) 20 | if err != nil { 21 | return "", fmt.Errorf("tcp connection failed: %w", err) 22 | } 23 | 24 | x509Certificates := conn.ConnectionState().PeerCertificates 25 | if len(x509Certificates) == 0 { 26 | return "", fmt.Errorf("no certificates returned from %s", addr) 27 | } 28 | // get last certificate in the chain 29 | fingerprint := sha1.Sum(x509Certificates[len(x509Certificates)-1].Raw) 30 | return fmt.Sprintf("%x", fingerprint), nil 31 | } 32 | 33 | func getHostAndPort(addr string) (string, error) { 34 | u, err := url.Parse(addr) 35 | if err != nil { 36 | return "", err 37 | } 38 | if u.Scheme == "" { 39 | return getHostAndPort(fmt.Sprintf("https://%s", addr)) 40 | } 41 | 42 | port := u.Port() 43 | if port != "0" && port != "" { 44 | return u.Host, nil 45 | } 46 | if u.Scheme == "http" { 47 | return fmt.Sprintf("%s:80", u.Host), nil 48 | } 49 | // default to 443 50 | return fmt.Sprintf("%s:443", u.Host), nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/aws/cert_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func Test_getAddr(t *testing.T) { 11 | tcs := []struct { 12 | host string 13 | expected string 14 | }{ 15 | {"https://test.com", "test.com:443"}, 16 | {"http://test.com", "test.com:80"}, 17 | {"test.com", "test.com:443"}, 18 | {"https://test.com/some/path", "test.com:443"}, 19 | {"http://test.com/some/path", "test.com:80"}, 20 | {"test.com/some/path", "test.com:443"}, 21 | } 22 | 23 | for _, tc := range tcs { 24 | actual, err := getHostAndPort(tc.host) 25 | require.NoError(t, err) 26 | assert.Equal(t, tc.expected, actual, fmt.Sprintf("host: %s", tc.host)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /internal/aws/client.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/aws/aws-sdk-go-v2/aws" 8 | "github.com/aws/aws-sdk-go-v2/aws/transport/http" 9 | "github.com/aws/aws-sdk-go-v2/config" 10 | "github.com/aws/aws-sdk-go-v2/service/cloudtrail" 11 | cloudtrailtypes "github.com/aws/aws-sdk-go-v2/service/cloudtrail/types" 12 | "github.com/aws/aws-sdk-go-v2/service/eks" 13 | "github.com/aws/aws-sdk-go-v2/service/iam" 14 | "github.com/aws/aws-sdk-go-v2/service/sts" 15 | "github.com/pete911/kubectl-iam4sa/internal/errs" 16 | "log/slog" 17 | "time" 18 | ) 19 | 20 | const eventsHours = 12 21 | 22 | type Client struct { 23 | logger *slog.Logger 24 | clusterName string 25 | account string 26 | region string 27 | iamClient *iam.Client 28 | cloudTrailClient *cloudtrail.Client 29 | eksClient *eks.Client 30 | } 31 | 32 | func NewClient(logger *slog.Logger, region, clusterName string) (Client, error) { 33 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 34 | defer cancel() 35 | 36 | cfg, err := config.LoadDefaultConfig(ctx) 37 | if err != nil { 38 | return Client{}, err 39 | } 40 | 41 | // we should use the same region as is in the kubeconfig, if this is not the case, log warning 42 | if region == "" { 43 | logger.Warn(fmt.Sprintf("no region supplied, defaulting to %s, this can be different from cluster region", cfg.Region)) 44 | } else { 45 | cfg.Region = region 46 | } 47 | 48 | out, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) 49 | if err != nil { 50 | return Client{}, err 51 | } 52 | account := aws.ToString(out.Account) 53 | 54 | return Client{ 55 | logger: logger, 56 | clusterName: clusterName, 57 | account: account, 58 | region: cfg.Region, 59 | iamClient: iam.NewFromConfig(cfg), 60 | cloudTrailClient: cloudtrail.NewFromConfig(cfg), 61 | eksClient: eks.NewFromConfig(cfg), 62 | }, nil 63 | } 64 | 65 | func (c Client) GetIAMRole(roleName string) (Role, error) { 66 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 67 | defer cancel() 68 | 69 | out, err := c.iamClient.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(roleName)}) 70 | if err != nil { 71 | err = handleResponseError(err, fmt.Sprintf("role %s", roleName)) 72 | return Role{}, err 73 | } 74 | return c.toRole(out.Role), nil 75 | } 76 | 77 | func (c Client) LookupEvents(namespace, serviceAccount string) (Events, error) { 78 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 79 | defer cancel() 80 | 81 | username := fmt.Sprintf("system:serviceaccount:%s:%s", namespace, serviceAccount) 82 | in := &cloudtrail.LookupEventsInput{ 83 | LookupAttributes: []cloudtrailtypes.LookupAttribute{{ 84 | AttributeKey: cloudtrailtypes.LookupAttributeKeyUsername, 85 | AttributeValue: aws.String(username), 86 | }}, 87 | StartTime: aws.Time(time.Now().Add(-(eventsHours * time.Hour))), 88 | } 89 | 90 | var events []cloudtrailtypes.Event 91 | for { 92 | out, err := c.cloudTrailClient.LookupEvents(ctx, in) 93 | if err != nil { 94 | err = handleResponseError(err, fmt.Sprintf("events for %s user", username)) 95 | return nil, err 96 | } 97 | events = append(events, out.Events...) 98 | if aws.ToString(out.NextToken) == "" { 99 | break 100 | } 101 | in.NextToken = out.NextToken 102 | } 103 | return c.toEvents(events), nil 104 | } 105 | 106 | func (c Client) DescribeCluster() (Cluster, error) { 107 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 108 | defer cancel() 109 | 110 | out, err := c.eksClient.DescribeCluster(ctx, &eks.DescribeClusterInput{Name: aws.String(c.clusterName)}) 111 | if err != nil { 112 | err = handleResponseError(err, fmt.Sprintf("cluster %s", c.clusterName)) 113 | return Cluster{}, err 114 | } 115 | return c.toCluster(out.Cluster), nil 116 | } 117 | 118 | func (c Client) GetClusterOidcProvider(clusterOidcIssuerId string) (OidcProvider, error) { 119 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 120 | defer cancel() 121 | 122 | arn := fmt.Sprintf("arn:aws:iam::%s:oidc-provider/oidc.eks.%s.amazonaws.com/id/%s", c.account, c.region, clusterOidcIssuerId) 123 | out, err := c.iamClient.GetOpenIDConnectProvider(ctx, &iam.GetOpenIDConnectProviderInput{OpenIDConnectProviderArn: aws.String(arn)}) 124 | if err != nil { 125 | err = handleResponseError(err, fmt.Sprintf("oidc provider %s", arn)) 126 | return OidcProvider{}, err 127 | } 128 | return toOidcProvider(out, arn), nil 129 | } 130 | 131 | // handleResponseError converts error to custom error (if possible) to make handling of errors easier 132 | func handleResponseError(err error, requestName string) error { 133 | var responseError *http.ResponseError 134 | if errors.As(err, &responseError) && responseError.HTTPStatusCode() == 404 { 135 | return errs.NewErrNotFound(fmt.Sprintf("%s: not found", requestName)) 136 | } 137 | return fmt.Errorf("%s: %w", requestName, err) 138 | } 139 | -------------------------------------------------------------------------------- /internal/aws/cluster.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go-v2/aws" 5 | "github.com/aws/aws-sdk-go-v2/service/eks/types" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | type Cluster struct { 11 | Arn string 12 | Name string 13 | Certificate string 14 | CreatedAt time.Time 15 | Endpoint string 16 | OidcIssuer string 17 | RoleArn string 18 | Status string 19 | } 20 | 21 | func (c Cluster) OidcIssuerFingerprint() (string, error) { 22 | return FingerprintSHA1(c.OidcIssuer, false) 23 | } 24 | 25 | func (c Cluster) OidcIssuerId() string { 26 | parts := strings.Split(c.OidcIssuer, "/") 27 | return parts[len(parts)-1] 28 | } 29 | 30 | func (c Client) toCluster(cluster *types.Cluster) Cluster { 31 | return Cluster{ 32 | Arn: aws.ToString(cluster.Arn), 33 | Name: aws.ToString(cluster.Name), 34 | Certificate: aws.ToString(cluster.CertificateAuthority.Data), 35 | CreatedAt: aws.ToTime(cluster.CreatedAt), 36 | Endpoint: aws.ToString(cluster.Endpoint), 37 | OidcIssuer: aws.ToString(cluster.Identity.Oidc.Issuer), 38 | RoleArn: aws.ToString(cluster.RoleArn), 39 | Status: string(cluster.Status), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/aws/event.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | "github.com/aws/aws-sdk-go-v2/service/cloudtrail/types" 8 | "time" 9 | ) 10 | 11 | type Events []Event 12 | 13 | func (e Events) FailedEvents() Events { 14 | var out Events 15 | for _, event := range e { 16 | if event.ErrorMessage != "" || event.ErrorCode != "" { 17 | out = append(out, event) 18 | } 19 | } 20 | return out 21 | } 22 | 23 | type Event struct { 24 | EventTime time.Time `json:"-"` 25 | EventId string `json:"-"` 26 | EventSource string `json:"-"` 27 | EventName string `json:"-"` 28 | UserName string `json:"-"` 29 | ErrorCode string `json:"errorCode"` // set when there's error 30 | ErrorMessage string `json:"errorMessage"` // set when there's error 31 | UserIdentity UserIdentity `json:"userIdentity"` 32 | Region string `json:"awsRegion"` 33 | SourceIP string `json:"sourceIPAddress"` 34 | UserAgent string `json:"userAgent"` 35 | RequestParameters RequestParameters `json:"requestParameters"` 36 | RequestId string `json:"requestId"` 37 | EventType string `json:"eventType"` 38 | } 39 | 40 | type UserIdentity struct { 41 | Type string `json:"type"` 42 | PrincipalId string `json:"principalId"` 43 | UserName string `json:"userName"` 44 | IdentityProvider string `json:"identityProvider"` 45 | } 46 | 47 | type RequestParameters struct { 48 | RoleArn string `json:"roleArn"` 49 | RoleSessionName string `json:"roleSessionName"` 50 | } 51 | 52 | func (c Client) toEvents(events []types.Event) Events { 53 | var out []Event 54 | for _, e := range events { 55 | // set these fields from response, if json unmarshal fails, at least we have some info 56 | event := Event{ 57 | EventTime: aws.ToTime(e.EventTime), 58 | EventId: aws.ToString(e.EventId), 59 | EventSource: aws.ToString(e.EventSource), 60 | EventName: aws.ToString(e.EventName), 61 | UserName: aws.ToString(e.Username), 62 | } 63 | if err := json.Unmarshal([]byte(aws.ToString(e.CloudTrailEvent)), &event); err != nil { 64 | c.logger.Warn(fmt.Sprintf("unmrshal %s event: %v", event.EventId, err)) 65 | } 66 | out = append(out, event) 67 | } 68 | return out 69 | } 70 | -------------------------------------------------------------------------------- /internal/aws/oidc.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go-v2/aws" 5 | "github.com/aws/aws-sdk-go-v2/service/iam" 6 | "time" 7 | ) 8 | 9 | type OidcProvider struct { 10 | Arn string 11 | ClientIDs []string 12 | CreateDate time.Time 13 | Thumbprints []string 14 | Url string 15 | } 16 | 17 | func toOidcProvider(oidc *iam.GetOpenIDConnectProviderOutput, arn string) OidcProvider { 18 | return OidcProvider{ 19 | Arn: arn, 20 | ClientIDs: oidc.ClientIDList, 21 | CreateDate: aws.ToTime(oidc.CreateDate), 22 | Thumbprints: oidc.ThumbprintList, 23 | Url: aws.ToString(oidc.Url), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/aws/role.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aws/aws-sdk-go-v2/aws" 6 | "github.com/aws/aws-sdk-go-v2/service/iam/types" 7 | "net/url" 8 | "time" 9 | ) 10 | 11 | type Role struct { 12 | ARN string 13 | Name string 14 | Description string 15 | AssumeRolePolicyDocument string 16 | CreateDate time.Time 17 | RoleLastUsed time.Time 18 | } 19 | 20 | func (c Client) toRole(role *types.Role) Role { 21 | roleName := aws.ToString(role.RoleName) 22 | document, err := url.QueryUnescape(aws.ToString(role.AssumeRolePolicyDocument)) 23 | if err != nil { 24 | c.logger.Warn(fmt.Sprintf("unescape %s assume role policy: %v", roleName, err)) 25 | } 26 | 27 | return Role{ 28 | ARN: aws.ToString(role.Arn), 29 | Name: roleName, 30 | Description: aws.ToString(role.Description), 31 | AssumeRolePolicyDocument: document, 32 | CreateDate: aws.ToTime(role.CreateDate), 33 | RoleLastUsed: aws.ToTime(role.RoleLastUsed.LastUsedDate), 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/errs/errors.go: -------------------------------------------------------------------------------- 1 | package errs 2 | 3 | type ErrNotFound struct { 4 | msg string 5 | } 6 | 7 | func NewErrNotFound(msg string) *ErrNotFound { 8 | return &ErrNotFound{msg: msg} 9 | } 10 | 11 | func (e *ErrNotFound) Error() string { 12 | return e.msg 13 | } 14 | -------------------------------------------------------------------------------- /internal/k8s/client.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "k8s.io/client-go/kubernetes" 8 | corev1 "k8s.io/client-go/kubernetes/typed/core/v1" 9 | "log/slog" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const iamRoleARNAnnotation = "eks.amazonaws.com/role-arn" 15 | 16 | type ServiceAccount struct { 17 | Name string 18 | Namespace string 19 | IamRoleArn string 20 | Pods []string 21 | } 22 | 23 | func (s ServiceAccount) RoleAccount() string { 24 | parts := strings.Split(s.IamRoleArn, ":") 25 | return parts[len(parts)-2] 26 | } 27 | 28 | func (s ServiceAccount) RoleName() string { 29 | parts := strings.Split(s.IamRoleArn, "/") 30 | return parts[len(parts)-1] 31 | } 32 | 33 | type Client struct { 34 | logger *slog.Logger 35 | config Kubeconfig 36 | coreV1 corev1.CoreV1Interface 37 | } 38 | 39 | func NewClient(logger *slog.Logger, config Kubeconfig) (Client, error) { 40 | cs, err := kubernetes.NewForConfig(config.RestConfig) 41 | if err != nil { 42 | return Client{}, err 43 | } 44 | return Client{ 45 | logger: logger, 46 | config: config, 47 | coreV1: cs.CoreV1(), 48 | }, nil 49 | } 50 | 51 | func (c Client) ListIAMServiceAccounts(namespace, labelSelector, fieldSelector string) ([]ServiceAccount, error) { 52 | if namespace == "" { 53 | return c.listAllIAMServiceAccounts(labelSelector, fieldSelector) 54 | } 55 | return c.listIAMServiceAccounts(namespace, labelSelector, fieldSelector) 56 | } 57 | 58 | func (c Client) listAllIAMServiceAccounts(labelSelector, fieldSelector string) ([]ServiceAccount, error) { 59 | namespaces, err := c.getNamespaces() 60 | if err != nil { 61 | return nil, fmt.Errorf("get namespaces: %w", err) 62 | } 63 | 64 | var allServiceAccounts []ServiceAccount 65 | for _, namespace := range namespaces { 66 | serviceAccounts, err := c.listIAMServiceAccounts(namespace, labelSelector, fieldSelector) 67 | if err != nil { 68 | return nil, err 69 | } 70 | allServiceAccounts = append(allServiceAccounts, serviceAccounts...) 71 | } 72 | return allServiceAccounts, nil 73 | } 74 | 75 | func (c Client) listIAMServiceAccounts(namespace, labelSelector, fieldSelector string) ([]ServiceAccount, error) { 76 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 77 | defer cancel() 78 | 79 | serviceAccountList, err := c.coreV1.ServiceAccounts(namespace).List(ctx, metav1.ListOptions{LabelSelector: labelSelector, FieldSelector: fieldSelector}) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | var serviceAccounts []ServiceAccount 85 | for _, serviceAccount := range serviceAccountList.Items { 86 | if roleARN, ok := serviceAccount.Annotations[iamRoleARNAnnotation]; ok { 87 | pods, err := c.listPods(namespace, serviceAccount.Name) 88 | if err != nil { 89 | return nil, fmt.Errorf("list pods for %s/%s service account: %v", namespace, serviceAccount.Name, err) 90 | } 91 | serviceAccounts = append(serviceAccounts, ServiceAccount{ 92 | Name: serviceAccount.Name, 93 | Namespace: serviceAccount.Namespace, 94 | IamRoleArn: roleARN, 95 | Pods: pods, 96 | }) 97 | } 98 | } 99 | return serviceAccounts, nil 100 | } 101 | 102 | func (c Client) listPods(namespace, serviceAccountName string) ([]string, error) { 103 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 104 | defer cancel() 105 | 106 | podList, err := c.coreV1.Pods(namespace).List(ctx, metav1.ListOptions{FieldSelector: fmt.Sprintf("spec.serviceAccountName=%s", serviceAccountName)}) 107 | if err != nil { 108 | return nil, err 109 | } 110 | var out []string 111 | for _, pod := range podList.Items { 112 | out = append(out, pod.Name) 113 | } 114 | return out, nil 115 | } 116 | 117 | func (c Client) getNamespaces() ([]string, error) { 118 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 119 | defer cancel() 120 | 121 | namespaceList, err := c.coreV1.Namespaces().List(ctx, metav1.ListOptions{}) 122 | if err != nil { 123 | return nil, err 124 | } 125 | var out []string 126 | for _, ns := range namespaceList.Items { 127 | out = append(out, ns.Name) 128 | } 129 | return out, nil 130 | } 131 | -------------------------------------------------------------------------------- /internal/k8s/kubeconfig.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | "k8s.io/client-go/rest" 6 | "k8s.io/client-go/tools/clientcmd" 7 | "k8s.io/client-go/tools/clientcmd/api" 8 | ) 9 | 10 | type Kubeconfig struct { 11 | RestConfig *rest.Config 12 | ClusterName string 13 | Region string 14 | Profile string 15 | } 16 | 17 | func (k Kubeconfig) String() string { 18 | return fmt.Sprintf("cluster name: %s region %s", k.ClusterName, k.Region) 19 | } 20 | 21 | func NewKubeconfig(kubeconfigPath string) (Kubeconfig, error) { 22 | clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 23 | &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}, 24 | nil) 25 | 26 | apiConfig, err := clientConfig.RawConfig() 27 | if err != nil { 28 | return Kubeconfig{}, fmt.Errorf("raw config: %v", err) 29 | } 30 | 31 | restConfig, err := clientConfig.ClientConfig() 32 | if err != nil { 33 | return Kubeconfig{}, fmt.Errorf("client configs: %v", err) 34 | } 35 | 36 | user := apiConfig.Contexts[apiConfig.CurrentContext].AuthInfo 37 | exec := apiConfig.AuthInfos[user].Exec 38 | if exec.Command != "aws" { 39 | if exec.Command == "" { 40 | return Kubeconfig{}, fmt.Errorf("exec command is not set in current %s contex, cannot determine cluster name and region", apiConfig.CurrentContext) 41 | } 42 | return Kubeconfig{}, fmt.Errorf("unexpected exec command %s for current %s contex, expected 'aws'", exec.Command, apiConfig.CurrentContext) 43 | } 44 | 45 | env := execEnvToMap(exec.Env) 46 | return Kubeconfig{ 47 | RestConfig: restConfig, 48 | ClusterName: getClusterName(exec.Args), 49 | Region: getRegion(exec.Args, env), 50 | Profile: getProfile(exec.Args, env), 51 | }, nil 52 | } 53 | 54 | func getRegion(args []string, env map[string]string) string { 55 | if v := getFlagValue(args, "--region"); v != "" { 56 | return v 57 | } 58 | return env["AWS_REGION"] 59 | } 60 | 61 | func getProfile(args []string, env map[string]string) string { 62 | if v := getFlagValue(args, "--profile"); v != "" { 63 | return v 64 | } 65 | return env["AWS_PROFILE"] 66 | } 67 | 68 | func getClusterName(args []string) string { 69 | return getFlagValue(args, "--cluster-name") 70 | } 71 | 72 | func getFlagValue(args []string, flag string) string { 73 | for i := range args { 74 | if args[i] == flag && len(args) > i+1 { 75 | return args[i+1] 76 | } 77 | } 78 | return "" 79 | } 80 | 81 | func execEnvToMap(env []api.ExecEnvVar) map[string]string { 82 | out := make(map[string]string) 83 | for _, v := range env { 84 | out[v.Name] = v.Value 85 | } 86 | return out 87 | } 88 | -------------------------------------------------------------------------------- /internal/k8s/kubeconfig_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func Test_getFlagValue(t *testing.T) { 10 | tcs := []struct { 11 | args []string 12 | flag string 13 | expected string 14 | }{ 15 | {[]string{"--profile", "default", "--region", "eu-west-2"}, "--profile", "default"}, 16 | {[]string{"--profile", "default", "--region", "eu-west-2"}, "--region", "eu-west-2"}, 17 | {[]string{"--profile", "default", "--region"}, "--region", ""}, 18 | } 19 | 20 | for _, tc := range tcs { 21 | actual := getFlagValue(tc.args, tc.flag) 22 | assert.Equal(t, tc.expected, actual, fmt.Sprintf("args: %v flag %s", tc.args, tc.flag)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/out/table.go: -------------------------------------------------------------------------------- 1 | package out 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "strings" 8 | "text/tabwriter" 9 | ) 10 | 11 | type Table struct { 12 | logger *slog.Logger 13 | writer *tabwriter.Writer 14 | } 15 | 16 | func NewTable(logger *slog.Logger) Table { 17 | return Table{ 18 | logger: logger, 19 | writer: tabwriter.NewWriter(os.Stdout, 1, 1, 2, ' ', 0), 20 | } 21 | } 22 | 23 | func (t Table) AddRow(columns ...string) { 24 | if _, err := fmt.Fprintln(t.writer, strings.Join(columns, "\t")); err != nil { 25 | t.logger.Error(fmt.Sprintf("table: add row: %v", err)) 26 | } 27 | } 28 | 29 | func (t Table) Print() { 30 | if err := t.writer.Flush(); err != nil { 31 | t.logger.Error(fmt.Sprintf("table: print: %v", err)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/pete911/kubectl-iam4sa/cmd" 5 | "os" 6 | ) 7 | 8 | var Version = "dev" 9 | 10 | func main() { 11 | cmd.Version = Version 12 | if err := cmd.RootCmd.Execute(); err != nil { 13 | os.Exit(1) 14 | } 15 | } 16 | --------------------------------------------------------------------------------