diff --git a/.github/workflows/app-ci.yaml b/.github/workflows/app-ci.yaml index e069bcc..c9cd9ba 100644 --- a/.github/workflows/app-ci.yaml +++ b/.github/workflows/app-ci.yaml @@ -59,13 +59,14 @@ jobs: with: name: golangci-lint-test-results path: lint-report.xml - - uses: dorny/test-reporter@v1.6.0 - with: - # artifact: golangci-lint-test-results - name: golangci-lint-test-results - path: 'lint-report.xml' - reporter: java-junit - fail-on-error: 'false' +# - name: Test report +# uses: dorny/test-reporter@v1.7.0 +# if: success() || failure() # run this step even if previous step failed +# with: +# name: golangci-lint +# path: 'lint-report.xml' +# reporter: java-junit +# fail-on-error: 'false' test: name: Go Test runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 6f2dc77..673b43e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ go.work dist/ +build/ +.idea/ +.env +k8s/ +.editorconfig +TODO \ No newline at end of file diff --git a/README.md b/README.md index e4e9443..86a1b3d 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ data: [] ``` -While using this tool, it enables you to deplou `aws-auth` ConfigMap to tool's namespace and provide PermissionSet's name instead of role ARN under `mapRoles` key: +While using this tool, it enables you to deploy `aws-auth` ConfigMap to tool's namespace and provide PermissionSet's name instead of role ARN under `mapRoles` key: ```yaml apiVersion: v1 @@ -57,6 +57,46 @@ data: [] ``` +The Tool also allows a placeholder `$ACCOUNTID` in the role ARN, to give a generic role. This is for a specific use case where multiple clusters in multiple accounts use a role of the same name, but the ARN changes because of account ID. + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: aws-auth + namespace: aws-iam-authenticator-sso-wrapper +data: + mapAccounts: | + [] + mapRoles: | + - "groups": + - "system:masters" + "rolearn": "arn:aws:iam::$ACCOUNTID:role/generic-role" + "username": "AdminRole:{{SessionName}}" + mapUsers: | + [] +``` +The above config map retrieves the AWS account ID and replaces `$ACCOUNTID` + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: aws-auth + namespace: kube-system +data: + mapAccounts: | + [] + mapRoles: | + - "groups": + - "system:masters" + "rolearn": "arn:aws:iam::123456789:role/generic-role" + "username": "AdminRole:{{SessionName}}" + mapUsers: | + [] +``` + + The tool will process `aws-auth` ConfigMap from it's local kubernetes namespace and transform it to the format AWS EKS cluster expects. After processing ConfigMap, it's output is saved `kube-system` namespace where PermissionSet's name is translated to corresponding role ARN, meaning `"permissionset": AdminRole"` line will become `"rolearn": "arn:aws:iam::000000000000:role/AWSReservedSSO_AdminRole_0123456789abcdef"` More details on this problem can found on below issues: @@ -99,7 +139,10 @@ Docker image can be obtained from [justinasb/aws-iam-authenticator-sso-wrapper]( { "Sid": "VisualEditor0", "Effect": "Allow", - "Action": "iam:ListRoles", + "Action": [ + "iam:ListRoles", + "sts:GetCallerIdentity" + ], "Resource": "*" } ] diff --git a/Tiltfile b/Tiltfile new file mode 100644 index 0000000..25f5620 --- /dev/null +++ b/Tiltfile @@ -0,0 +1,74 @@ +# -*- mode: Python -*- + +PROJECT_NAME = 'aws-iam-authenticator-sso-wrapper' +IMAGE_REGISTRY = 'ttl.sh' +IMAGE_REPO = 'aws-iam-authenticator-sso-wrapper' + +# Building go binary locally +local_resource( + name = 'build', + cmd = 'GOOS=linux CGO_ENABLED=0 GOARCH=amd64 go build -gcflags "all=-N -l" -o ./build/%s .' % PROJECT_NAME, + deps = [ # list of file that will trigger rebuild when modified + './main.go', + './aws.go', + './kubernetes.go', + './type.go' + ], +) + +# Set the default registry where image will be pushed to after build and from where it will be pulled in k8s +default_registry('%s/%s' % (IMAGE_REGISTRY, IMAGE_REPO)) + +# Use custom Dockerfile for Tilt builds, which only takes locally built binary for live reloading. +dockerfile = ''' + FROM golang:1.19-alpine + RUN go install github.com/go-delve/delve/cmd/dlv@latest + COPY ./build/%s /usr/local/bin/%s + ''' % (PROJECT_NAME, PROJECT_NAME) + +# Wrap a docker_build to restart the given entrypoint after a Live Update. +load('ext://restart_process', 'docker_build_with_restart') +docker_build_with_restart( + ref = PROJECT_NAME, + context = '.', + dockerfile_contents = dockerfile, + entrypoint = '/go/bin/dlv --listen=0.0.0.0:50100 --api-version=2 --headless=true --only-same-user=false --accept-multiclient --check-go-version=false exec /usr/local/bin/%s -- -dst-namespace=%s -dst-configmap=aws-auth-dst -src-configmap=aws-auth-src -interval=1800 -debug' % (PROJECT_NAME, PROJECT_NAME), + only = './build/%s' % PROJECT_NAME, # trigger docker image rebuild only if go binary has been recompiled + live_update = [ + # Copy the binary so it gets restarted. + sync( + local_path = PROJECT_NAME, + remote_path = '/usr/local/bin/%s' % PROJECT_NAME, + ), + ], + +) + +# Allow the cluster to avoid problems while having kubectl configured to talk to a remote cluster. +allow_k8s_contexts(k8s_context()) + +# Load .env file +load('ext://dotenv', 'dotenv') +dotenv() + +# Provision kubernetes resources +k8s_yaml('tilt-files/namespace.yaml') +k8s_yaml('tilt-files/configMap.yaml') +k8s_yaml('tilt-files/deployment.yaml') +k8s_yaml('tilt-files/roles.yaml') + +# Replace IAM role used on SA with one defined in .env file +objects = read_yaml_stream('tilt-files/serviceAccount.yaml') +for o in objects: + o['metadata']['annotations']['eks.amazonaws.com/role-arn'] = os.environ['IAM_ROLE'] +k8s_yaml(encode_yaml_stream(objects)) + +# Configure port-forwarding for delve +k8s_resource( + workload = PROJECT_NAME, + port_forwards = ["50100:50100"], # Set up the K8s port-forward to be able to connect to it locally. + resource_deps = [ + 'build', + ], +) + diff --git a/aws.go b/aws.go index 747f59c..e94a583 100644 --- a/aws.go +++ b/aws.go @@ -8,26 +8,24 @@ import ( "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" + "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/aws/aws-sdk-go-v2/service/iam/types" "go.uber.org/zap" "golang.org/x/exp/slices" ) -// getAWSClient returns an Amazon IAM service client. +// getAWSClientConfig returns an aws.Config to be used on clients. // -// It initializes the AWS SDK and creates an Amazon IAM service client using the default configuration. -// It takes no parameters and returns a pointer to an iam.Client and an error. -func getAWSClient() (*iam.Client, error) { +// It initializes the AWS SDK and creates an Amazon client configuration. +// It takes no parameters and returns a aws.Config and an error. +func getAWSClientConfig() (aws.Config, error) { // Initialize AWS SDK cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(defaultAWSRegion)) if err != nil { - return nil, err + return cfg, err } - // Create an Amazon IAM service client - client := iam.NewFromConfig(cfg) - - return client, nil + return cfg, nil } // listSSORoles retrieves a list of IAM roles that are used by AWS SSO service. @@ -41,11 +39,12 @@ func listSSORoles() ([]types.Role, error) { logger.Info("Retrieving SSO roles from AWS IAM...") - client, err := getAWSClient() + cfg, err := getAWSClientConfig() if err != nil { logger.Fatal("Unable to load SDK config, %v", zap.Error(err)) } - + client := iam.NewFromConfig(cfg) + // Create a list roles request params := &iam.ListRolesInput{ MaxItems: aws.Int32(10), @@ -118,3 +117,28 @@ func removePathFromRoleARN(arn string, path string) string { return r.ReplaceAllString(arn, "/") } + +// Get AWS account ID +func getAccountId() (string, error) { + logger.Debug("Reading AWS Account ID...") + + cfg, err := getAWSClientConfig() + if err != nil { + logger.Fatal("Unable to load SDK config, %v", zap.Error(err)) + } + + client := sts.NewFromConfig(cfg) + if err != nil { + logger.Fatal("Unable to load SDK config, %v", zap.Error(err)) + } + input := &sts.GetCallerIdentityInput{} + + req, err := client.GetCallerIdentity(context.TODO(), input) + if err != nil { + return "", err + } + + logger.Debug(fmt.Sprintf("Retrievied %s as AWS Account ID", *req.Account)) + + return *req.Account, nil +} diff --git a/go.mod b/go.mod index 95dce0c..96718c8 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,10 @@ require ( github.com/aws/aws-sdk-go-v2 v1.22.2 github.com/aws/aws-sdk-go-v2/config v1.22.3 github.com/aws/aws-sdk-go-v2/service/iam v1.27.1 + github.com/aws/aws-sdk-go-v2/service/sts v1.25.1 go.uber.org/zap v1.26.0 golang.org/x/exp v0.0.0-20230905200255-921286631fa9 + gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.28.3 k8s.io/apimachinery v0.28.3 @@ -23,7 +25,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.2 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.17.1 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.19.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.25.1 // indirect github.com/aws/smithy-go v1.16.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.9.0 // indirect @@ -57,7 +58,6 @@ require ( google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/klog/v2 v2.100.1 // indirect k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect diff --git a/kubernetes.go b/kubernetes.go index 0d046ff..72b4d61 100644 --- a/kubernetes.go +++ b/kubernetes.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/aws/aws-sdk-go-v2/service/iam/types" "go.uber.org/zap" @@ -142,7 +143,7 @@ func setConfigMap(clientset kubernetes.Interface, configMapName string, namespac // - awsIAMRoles: a slice of types.Role structs // // It returns a slice of SSORoleMapping structs, where the PermissionSet name is replaced with Role ARN. -func transformRoleMappings(roleMappings []SSORoleMapping, awsIAMRoles []types.Role) []SSORoleMapping { +func transformRoleMappings(roleMappings []SSORoleMapping, awsIAMRoles []types.Role, accountId string) []SSORoleMapping { // Replace PermissionSet name with Role ARN, if permission // set is not found - remove it from configMap @@ -155,9 +156,17 @@ func transformRoleMappings(roleMappings []SSORoleMapping, awsIAMRoles []types.Ro // Check if Role Mapping needs translation. If not, // skip this itteration and add object to updated list if (roleMapping.PermissionSet == "") || (roleMapping.RoleARN != "") { - logger.Debug("Role Mapping does not need to be translated", zap.Any("roleMapping", roleMapping)) - roleMappingsUpdated = append(roleMappingsUpdated, roleMapping) - continue + //Check if rolemapping requires fetching the accountid of the aws account + if(strings.Contains(roleMapping.RoleARN, "$ACCOUNTID")) { + logger.Info("Replacing $ACCOUNTID with Actual account ID") + roleMapping.RoleARN = strings.Replace(roleMapping.RoleARN, "$ACCOUNTID", accountId, -1) + roleMappingsUpdated = append(roleMappingsUpdated, roleMapping) + continue + } else { + logger.Debug("Role Mapping does not need to be translated", zap.Any("roleMapping", roleMapping)) + roleMappingsUpdated = append(roleMappingsUpdated, roleMapping) + continue + } } // Translate permission set name to ARN diff --git a/kubernetes_test.go b/kubernetes_test.go index 687a8e7..b4d3a45 100644 --- a/kubernetes_test.go +++ b/kubernetes_test.go @@ -228,7 +228,7 @@ func TestTransformRoleMappings(t *testing.T) { }, } - got := transformRoleMappings(mappings, roles) + got := transformRoleMappings(mappings, roles, "") if !reflect.DeepEqual(got, want) { t.Errorf("TransformRoleMappings() returned unexpected object: %+v, want %+v", got, want) @@ -275,7 +275,7 @@ func TestTransformRoleMappings(t *testing.T) { }, } - got := transformRoleMappings(mappings, roles) + got := transformRoleMappings(mappings, roles, "") if !reflect.DeepEqual(got, want) { t.Errorf("TransformRoleMappings() returned unexpected object: %+v, want %+v", got, want) @@ -327,7 +327,47 @@ func TestTransformRoleMappings(t *testing.T) { }, } - got := transformRoleMappings(mappings, roles) + got := transformRoleMappings(mappings, roles, "") + + if !reflect.DeepEqual(got, want) { + t.Errorf("TransformRoleMappings() returned unexpected object: %+v, want %+v", got, want) + } + }) + + // Test when ACCOUNTID placeholder was provided, if it is correctly translated + t.Run("Translate ACCOUNTID placeholder actual account ID", func(t *testing.T) { + mappings := []SSORoleMapping{ + { + RoleARN: "arn:aws:iam::$ACCOUNTID:role/admin-role", + PermissionSet: "", + Username: "", + Groups: []string{}, + }, + } + + roles := []types.Role{ + { + RoleName: aws.String("AWSReservedSSO_devops_0123456789abcdef"), + Path: aws.String("/aws-reserved/sso.amazonaws.com/eu-west-1/"), + Arn: aws.String("arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com/eu-west-1/AWSReservedSSO_devops_0123456789abcdef"), + }, + { + RoleName: aws.String("AWSReservedSSO_sre_0123456789abcdef"), + Path: aws.String("/aws-reserved/sso.amazonaws.com/eu-west-1/"), + Arn: aws.String("arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com/eu-west-1/AWSReservedSSO_sre_0123456789abcdef"), + }, + } + + want := []SSORoleMapping{ + { + RoleARN: "arn:aws:iam::123456789012:role/admin-role", + PermissionSet: "", + Username: "", + Groups: []string{}, + }, + } + + got := transformRoleMappings(mappings, roles, "123456789012") if !reflect.DeepEqual(got, want) { t.Errorf("TransformRoleMappings() returned unexpected object: %+v, want %+v", got, want) diff --git a/main.go b/main.go index 4261a15..2ff025a 100644 --- a/main.go +++ b/main.go @@ -1,24 +1,26 @@ package main import ( - "flag" - "fmt" - "os" - "os/signal" - "time" - - "go.uber.org/zap" - "gopkg.in/yaml.v3" + "flag" + "fmt" + "gopkg.in/yaml.v2" + "os" + "os/signal" + "time" + + "go.uber.org/zap" ) -var logger *zap.Logger -var sourceConfigMapName string -var sourceNamespaceName string -var destinationConfigMapName string -var destinationNamespaceName string -var defaultAWSRegion string -var debug bool -var interval int +var ( + logger *zap.Logger + sourceConfigMapName string + sourceNamespaceName string + destinationConfigMapName string + destinationNamespaceName string + defaultAWSRegion string + debug bool + interval int +) // init is a special function in Go that is automatically called before the main function. func init() { @@ -43,10 +45,10 @@ func main() { // No parameters. // No return type. func parseCliArgs() { - // Parce cli arguments + // Parse cli arguments flag.StringVar(&sourceConfigMapName, "src-configmap", "aws-auth", "Name of the source Kubernetes ConfigMap to read data from and perform transformation upon") - flag.StringVar(&sourceNamespaceName, "src-namespace", "", "Kubernetes namespace from which to read ConfigMap which containes mapRoles with permissionset names. If not defined, current namespace of pod will be used") - flag.StringVar(&destinationConfigMapName, "dst-configmap", "aws-auth", "Name of the destination Kubernets ConfigMap which will be updated after transformation") + flag.StringVar(&sourceNamespaceName, "src-namespace", "", "Kubernetes namespace from which to read ConfigMap which contains mapRoles with permissionset names. If not defined, current namespace of pod will be used") + flag.StringVar(&destinationConfigMapName, "dst-configmap", "aws-auth", "Name of the destination Kubernetes ConfigMap which will be updated after transformation") flag.StringVar(&destinationNamespaceName, "dst-namespace", "kube-system", "Name of the destination Kubernetes Namespace where new ConfigMap will be updated") flag.StringVar(&defaultAWSRegion, "aws-region", "us-east-1", "AWS region to use when interacting with IAM service") flag.BoolVar(&debug, "debug", false, "Enable debug logging") @@ -110,7 +112,7 @@ func scheduler(f func(), timeInterval time.Duration) chan bool { // updateRoleMappings updates the role mappings in the configMap. // // This function retrieves the current namespace where the pod is running and -// reads the configMap template from that namespace. It then unmarshals the +// reads the configMap template from that namespace. It then unmarshal the // RoleMappings from the configMap and reads all the SSO roles from AWS IAM. // The function replaces the PermissionSet name with the Role ARN and removes // the permission set from the configMap if it is not found. It then marshals @@ -155,8 +157,13 @@ func updateRoleMappings() { logger.Panic("Error occurred while retrieving SSO Roles for AWS IAM service", zap.Error(err)) } + accountId, err := getAccountId() + if err != nil { + logger.Panic("Failed to read AWS Account ID", zap.Error(err)) + } + // Replace PermissionSet name with Role ARN, if permission set is not found - remove it from configMap - roleMappingsUpdated := transformRoleMappings(roleMappings, awsIAMRoles) + roleMappingsUpdated := transformRoleMappings(roleMappings, awsIAMRoles, accountId) // Marshal new role mappings into string format and update configMap on destination namespace data, err := yaml.Marshal(roleMappingsUpdated) // Marshal new role mappings into string format @@ -164,7 +171,7 @@ func updateRoleMappings() { logger.Panic("Failed to marshal RoleMappings", zap.Error(err)) } - cmdata := configMap.Data // Read Data from existing configMap and replates "mapRoles" with new data + cmdata := configMap.Data // Read Data from existing configMap and replaces "mapRoles" with new data cmdata["mapRoles"] = string(data) err = setConfigMap(clientset, destinationConfigMapName, destinationNamespaceName, cmdata) // Update configMap diff --git a/tilt-files/configMap.yaml b/tilt-files/configMap.yaml new file mode 100644 index 0000000..174b6cb --- /dev/null +++ b/tilt-files/configMap.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: aws-auth-src + namespace: aws-iam-authenticator-sso-wrapper +data: + mapAccounts: | + [] + mapUsers: | + [] + mapRoles: | + - "groups": + - "system:masters" + "rolearn": "arn:aws:iam::$ACCOUNTID:role/admin-role" + "username": "admin:{{SessionName}}" + - "groups": + - "system:masters" + "permissionset": "platform-engineering-dev-pu" + "username": "platform-engineering:{{SessionName}}" \ No newline at end of file diff --git a/tilt-files/deployment.yaml b/tilt-files/deployment.yaml new file mode 100644 index 0000000..2526942 --- /dev/null +++ b/tilt-files/deployment.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + labels: + app: aws-iam-authenticator-sso-wrapper + app.kubernetes.io/name: aws-iam-authenticator-sso-wrapper + name: aws-iam-authenticator-sso-wrapper + namespace: aws-iam-authenticator-sso-wrapper +spec: + replicas: 1 + selector: + matchLabels: + app: aws-iam-authenticator-sso-wrapper + template: + metadata: + labels: + app: aws-iam-authenticator-sso-wrapper + spec: + serviceAccountName: aws-iam-authenticator-sso-wrapper + containers: + - name: aws-iam-authenticator-sso-wrapper + image: "aws-iam-authenticator-sso-wrapper" + imagePullPolicy: "IfNotPresent" + securityContext: + allowPrivilegeEscalation: false + command: ["./aws-iam-authenticator-sso-wrapper"] + resources: + limits: + cpu: 200m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi diff --git a/tilt-files/namespace.yaml b/tilt-files/namespace.yaml new file mode 100644 index 0000000..c212db0 --- /dev/null +++ b/tilt-files/namespace.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: aws-iam-authenticator-sso-wrapper +--- \ No newline at end of file diff --git a/tilt-files/roles.yaml b/tilt-files/roles.yaml new file mode 100644 index 0000000..9243994 --- /dev/null +++ b/tilt-files/roles.yaml @@ -0,0 +1,53 @@ +--- +# Source: aws-iam-authenticator-sso-wrapper/templates/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + namespace: aws-iam-authenticator-sso-wrapper + name: aws-auth-configmap-updater-dst +rules: + - apiGroups: [""] + resources: ["configmaps"] + # resourceNames: [ "aws-auth-dst" ] + # verbs: ["update", "get", "create"] + verbs: ["*"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: aws-auth-configmap-updater-src + namespace: aws-iam-authenticator-sso-wrapper +rules: + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: [ "aws-auth-src" ] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: aws-auth-configmap-updater-dst + namespace: aws-iam-authenticator-sso-wrapper +subjects: + - kind: ServiceAccount + name: aws-iam-authenticator-sso-wrapper + namespace: aws-iam-authenticator-sso-wrapper +roleRef: + kind: Role + name: aws-auth-configmap-updater-dst + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: aws-auth-configmap-updater-src + namespace: aws-iam-authenticator-sso-wrapper +subjects: + - kind: ServiceAccount + name: aws-iam-authenticator-sso-wrapper + namespace: aws-iam-authenticator-sso-wrapper +roleRef: + kind: Role + name: aws-auth-configmap-updater-src + apiGroup: rbac.authorization.k8s.io +--- \ No newline at end of file diff --git a/tilt-files/serviceAccount.yaml b/tilt-files/serviceAccount.yaml new file mode 100644 index 0000000..4481e4c --- /dev/null +++ b/tilt-files/serviceAccount.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: aws-iam-authenticator-sso-wrapper + namespace: aws-iam-authenticator-sso-wrapper + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::012345678901:role/aws-iam-authenticator-sso-wrapper +--- \ No newline at end of file