diff --git a/pkg/actions/accessentry/fakes/fake_getter.go b/pkg/actions/accessentry/fakes/fake_getter.go new file mode 100644 index 0000000000..8456352719 --- /dev/null +++ b/pkg/actions/accessentry/fakes/fake_getter.go @@ -0,0 +1,120 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package fakes + +import ( + "context" + "sync" + + "github.com/weaveworks/eksctl/pkg/actions/accessentry" + "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" +) + +type FakeGetterInterface struct { + GetStub func(context.Context, v1alpha5.ARN) ([]accessentry.Summary, error) + getMutex sync.RWMutex + getArgsForCall []struct { + arg1 context.Context + arg2 v1alpha5.ARN + } + getReturns struct { + result1 []accessentry.Summary + result2 error + } + getReturnsOnCall map[int]struct { + result1 []accessentry.Summary + result2 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeGetterInterface) Get(arg1 context.Context, arg2 v1alpha5.ARN) ([]accessentry.Summary, error) { + fake.getMutex.Lock() + ret, specificReturn := fake.getReturnsOnCall[len(fake.getArgsForCall)] + fake.getArgsForCall = append(fake.getArgsForCall, struct { + arg1 context.Context + arg2 v1alpha5.ARN + }{arg1, arg2}) + stub := fake.GetStub + fakeReturns := fake.getReturns + fake.recordInvocation("Get", []interface{}{arg1, arg2}) + fake.getMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeGetterInterface) GetCallCount() int { + fake.getMutex.RLock() + defer fake.getMutex.RUnlock() + return len(fake.getArgsForCall) +} + +func (fake *FakeGetterInterface) GetCalls(stub func(context.Context, v1alpha5.ARN) ([]accessentry.Summary, error)) { + fake.getMutex.Lock() + defer fake.getMutex.Unlock() + fake.GetStub = stub +} + +func (fake *FakeGetterInterface) GetArgsForCall(i int) (context.Context, v1alpha5.ARN) { + fake.getMutex.RLock() + defer fake.getMutex.RUnlock() + argsForCall := fake.getArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeGetterInterface) GetReturns(result1 []accessentry.Summary, result2 error) { + fake.getMutex.Lock() + defer fake.getMutex.Unlock() + fake.GetStub = nil + fake.getReturns = struct { + result1 []accessentry.Summary + result2 error + }{result1, result2} +} + +func (fake *FakeGetterInterface) GetReturnsOnCall(i int, result1 []accessentry.Summary, result2 error) { + fake.getMutex.Lock() + defer fake.getMutex.Unlock() + fake.GetStub = nil + if fake.getReturnsOnCall == nil { + fake.getReturnsOnCall = make(map[int]struct { + result1 []accessentry.Summary + result2 error + }) + } + fake.getReturnsOnCall[i] = struct { + result1 []accessentry.Summary + result2 error + }{result1, result2} +} + +func (fake *FakeGetterInterface) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.getMutex.RLock() + defer fake.getMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeGetterInterface) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ accessentry.GetterInterface = new(FakeGetterInterface) diff --git a/pkg/actions/accessentry/getter.go b/pkg/actions/accessentry/getter.go index 418f81a6a5..7c2f4c2638 100644 --- a/pkg/actions/accessentry/getter.go +++ b/pkg/actions/accessentry/getter.go @@ -10,6 +10,12 @@ import ( "github.com/weaveworks/eksctl/pkg/awsapi" ) +//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate +//counterfeiter:generate -o fakes/fake_getter.go . GetterInterface +type GetterInterface interface { + Get(ctx context.Context, principalARN api.ARN) ([]Summary, error) +} + type Getter struct { clusterName string eksAPI awsapi.EKS diff --git a/pkg/actions/accessentry/migrator.go b/pkg/actions/accessentry/migrator.go new file mode 100644 index 0000000000..b3a52c2ad8 --- /dev/null +++ b/pkg/actions/accessentry/migrator.go @@ -0,0 +1,340 @@ +package accessentry + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awseks "github.com/aws/aws-sdk-go-v2/service/eks" + ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" + awsiam "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/kris-nova/logger" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" + "github.com/weaveworks/eksctl/pkg/authconfigmap" + "github.com/weaveworks/eksctl/pkg/awsapi" + "github.com/weaveworks/eksctl/pkg/eks/waiter" + "github.com/weaveworks/eksctl/pkg/iam" + "github.com/weaveworks/eksctl/pkg/kubernetes" + "github.com/weaveworks/eksctl/pkg/utils/tasks" +) + +type MigrationOptions struct { + TargetAuthMode string + Approve bool + Timeout time.Duration +} + +type Migrator struct { + clusterName string + eksAPI awsapi.EKS + iamAPI awsapi.IAM + clientSet kubernetes.Interface + aeCreator CreatorInterface + aeGetter GetterInterface + curAuthMode ekstypes.AuthenticationMode + tgAuthMode ekstypes.AuthenticationMode +} + +func NewMigrator( + clusterName string, + eksAPI awsapi.EKS, + iamAPI awsapi.IAM, + clientSet kubernetes.Interface, + aeCreator CreatorInterface, + aeGetter GetterInterface, + curAuthMode ekstypes.AuthenticationMode, + tgAuthMode ekstypes.AuthenticationMode, +) *Migrator { + return &Migrator{ + clusterName: clusterName, + eksAPI: eksAPI, + iamAPI: iamAPI, + clientSet: clientSet, + aeCreator: aeCreator, + aeGetter: aeGetter, + curAuthMode: curAuthMode, + tgAuthMode: tgAuthMode, + } +} + +func (m *Migrator) MigrateToAccessEntry(ctx context.Context, options MigrationOptions) error { + if m.tgAuthMode != ekstypes.AuthenticationModeApi && m.tgAuthMode != ekstypes.AuthenticationModeApiAndConfigMap { + return fmt.Errorf("target authentication mode is invalid, must be either %s or %s", ekstypes.AuthenticationModeApi, ekstypes.AuthenticationModeApiAndConfigMap) + } + if m.curAuthMode == ekstypes.AuthenticationModeApi { + logger.Warning("cluster authentication mode is already %s; there is no need to migrate to access entries", ekstypes.AuthenticationModeApi) + return nil + } + logger.Info("current cluster authentication mode is %s; target cluster authentication mode is %s", m.curAuthMode, m.tgAuthMode) + + taskTree := tasks.TaskTree{ + Parallel: false, + PlanMode: !options.Approve, + } + + if m.curAuthMode == ekstypes.AuthenticationModeConfigMap { + taskTree.Append(&tasks.GenericTask{ + Description: fmt.Sprintf("update authentication mode from %v to %v", ekstypes.AuthenticationModeConfigMap, ekstypes.AuthenticationModeApiAndConfigMap), + Doer: func() error { + return m.doUpdateAuthenticationMode(ctx, ekstypes.AuthenticationModeApiAndConfigMap, options.Timeout) + }, + }) + } + + curAccessEntries, err := m.aeGetter.Get(ctx, api.ARN{}) + if err != nil && m.curAuthMode != ekstypes.AuthenticationModeConfigMap { + return fmt.Errorf("fetching existing access entries: %w", err) + } + + cmEntries, err := m.doGetIAMIdentityMappings(ctx) + if err != nil { + return err + } + + newAccessEntries, skipAPImode := doFilterAccessEntries(cmEntries, curAccessEntries) + if len(newAccessEntries) > 0 { + aeTasks := m.aeCreator.CreateTasks(ctx, newAccessEntries) + aeTasks.IsSubTask = true + taskTree.Append(aeTasks) + } + + if m.tgAuthMode == ekstypes.AuthenticationModeApi { + if skipAPImode { + logger.Warning("one or more iamidentitymapping(s) could not be migrated to access entry, will not update authentication mode to %v", ekstypes.AuthenticationModeApi) + } else { + taskTree.Append(&tasks.GenericTask{ + Description: fmt.Sprintf("update authentication mode from %v to %v", ekstypes.AuthenticationModeApiAndConfigMap, ekstypes.AuthenticationModeApi), + Doer: func() error { + return m.doUpdateAuthenticationMode(ctx, m.tgAuthMode, options.Timeout) + }, + }) + taskTree.Append(&tasks.GenericTask{ + Description: fmt.Sprintf("delete aws-auth configMap when authentication mode is %v", ekstypes.AuthenticationModeApi), + Doer: func() error { + return doDeleteAWSAuthConfigMap(ctx, m.clientSet, authconfigmap.ObjectNamespace, authconfigmap.ObjectName) + }, + }) + } + } + + return runAllTasks(&taskTree) +} + +func (m *Migrator) doUpdateAuthenticationMode(ctx context.Context, authMode ekstypes.AuthenticationMode, timeout time.Duration) error { + logger.Info("updating cluster authentication mode to %v", authMode) + output, err := m.eksAPI.UpdateClusterConfig(ctx, &awseks.UpdateClusterConfigInput{ + Name: aws.String(m.clusterName), + AccessConfig: &ekstypes.UpdateAccessConfigRequest{ + AuthenticationMode: authMode, + }, + }) + if err != nil { + return fmt.Errorf("failed to update cluster config: %v", err) + } + + updateWaiter := waiter.NewUpdateWaiter(m.eksAPI, func(options *waiter.UpdateWaiterOptions) { + options.RetryAttemptLogMessage = fmt.Sprintf("waiting for update %q in cluster %q to complete", *output.Update.Id, m.clusterName) + }) + err = updateWaiter.Wait(ctx, &awseks.DescribeUpdateInput{ + Name: &m.clusterName, + UpdateId: output.Update.Id, + }, timeout) + + switch e := err.(type) { + case *waiter.UpdateFailedError: + if e.Status == string(ekstypes.UpdateStatusCancelled) { + return fmt.Errorf("request to update cluster authentication mode was cancelled: %s", e.UpdateError) + } + return fmt.Errorf("failed to update cluster authentication mode: %s", e.UpdateError) + case nil: + logger.Info("authentication mode was successfully updated to %s on cluster %s", authMode, m.clusterName) + m.curAuthMode = authMode + return nil + default: + return err + } +} + +func (m *Migrator) doGetIAMIdentityMappings(ctx context.Context) ([]iam.Identity, error) { + acm, err := authconfigmap.NewFromClientSet(m.clientSet) + if err != nil { + return nil, err + } + + cmEntries, err := acm.GetIdentities() + if err != nil { + return nil, err + } + + for idx, cme := range cmEntries { + lastIdx := strings.LastIndex(cme.ARN(), "/") + cmeName := cme.ARN()[lastIdx+1:] + var noSuchEntity *types.NoSuchEntityException + + switch cme.Type() { + case iam.ResourceTypeRole: + roleCme := iam.RoleIdentity{ + RoleARN: cme.ARN(), + KubernetesIdentity: iam.KubernetesIdentity{ + KubernetesUsername: cme.Username(), + KubernetesGroups: cme.Groups(), + }, + } + + if cmeName != "" { + getRoleOutput, err := m.iamAPI.GetRole(ctx, &awsiam.GetRoleInput{RoleName: &cmeName}) + if err != nil { + if errors.As(err, &noSuchEntity) { + return nil, fmt.Errorf("role %q does not exists, either delete the iamidentitymapping using \"eksctl delete iamidentitymapping --cluster %s --arn %s\" or create the role in AWS", cmeName, m.clusterName, cme.ARN()) + } + return nil, err + } + roleCme.RoleARN = *getRoleOutput.Role.Arn + } + cmEntries[idx] = iam.Identity(roleCme) + + case iam.ResourceTypeUser: + userCme := iam.UserIdentity{ + UserARN: cme.ARN(), + KubernetesIdentity: iam.KubernetesIdentity{ + KubernetesUsername: cme.Username(), + KubernetesGroups: cme.Groups(), + }, + } + + if cmeName != "" { + getUserOutput, err := m.iamAPI.GetUser(ctx, &awsiam.GetUserInput{UserName: &cmeName}) + if err != nil { + if errors.As(err, &noSuchEntity) { + return nil, fmt.Errorf("user %q does not exists, either delete the iamidentitymapping using \"eksctl delete iamidentitymapping --cluster %s --arn %s\" or create the user in AWS", cmeName, m.clusterName, cme.ARN()) + } + return nil, err + } + userCme.UserARN = *getUserOutput.User.Arn + } + cmEntries[idx] = iam.Identity(userCme) + } + } + + return cmEntries, nil +} + +func doFilterAccessEntries(cmEntries []iam.Identity, accessEntries []Summary) ([]api.AccessEntry, bool) { + + skipAPImode := false + var toDoEntries []api.AccessEntry + uniqueCmEntries := map[string]struct{}{} + aeArns := map[string]struct{}{} + + // Create map for current access entry principal ARN + for _, ae := range accessEntries { + aeArns[ae.PrincipalARN] = struct{}{} + } + + for _, cme := range cmEntries { + if _, ok := uniqueCmEntries[cme.ARN()]; !ok { // Check if cmEntry is not duplicate + uniqueCmEntries[cme.ARN()] = struct{}{} // Add ARN to cmEntries map + + if _, ok := aeArns[cme.ARN()]; !ok { // Check if the principal ARN is not present in existing access entries + switch cme.Type() { + case iam.ResourceTypeRole: + if strings.Contains(cme.ARN(), ":role/aws-service-role/") { // Check if the principal ARN is service-linked-role + logger.Warning("found service-linked role iamidentitymapping \"%s\", can not create access entry, skipping", cme.ARN()) + skipAPImode = true + } else if cme.Username() == authconfigmap.RoleNodeGroupUsername { + aeEntry := doBuildNodeRoleAccessEntry(cme) + toDoEntries = append(toDoEntries, *aeEntry) + } else if aeEntry := doBuildAccessEntry(cme); aeEntry != nil { + toDoEntries = append(toDoEntries, *aeEntry) + } else { + skipAPImode = true + } + case iam.ResourceTypeUser: + if aeEntry := doBuildAccessEntry(cme); aeEntry != nil { + toDoEntries = append(toDoEntries, *aeEntry) + } else { + skipAPImode = true + } + case iam.ResourceTypeAccount: + logger.Warning("found account iamidentitymapping %q, cannot create access entry, skipping", cme.Account()) + skipAPImode = true + } + } else { + logger.Warning("%s already exists in access entry, skipping", cme.ARN()) + } + } + } + + return toDoEntries, skipAPImode +} + +func doBuildNodeRoleAccessEntry(cme iam.Identity) *api.AccessEntry { + isLinux := true + + for _, group := range cme.Groups() { + if group == "eks:kube-proxy-windows" { + isLinux = false + } + } + // For Linux Nodes + if isLinux { + return &api.AccessEntry{ + PrincipalARN: api.MustParseARN(cme.ARN()), + Type: "EC2_LINUX", + } + } + // For Windows Nodes + return &api.AccessEntry{ + PrincipalARN: api.MustParseARN(cme.ARN()), + Type: "EC2_WINDOWS", + } +} + +func doBuildAccessEntry(cme iam.Identity) *api.AccessEntry { + containsSys := false + + for _, group := range cme.Groups() { + if strings.HasPrefix(group, "system:") { + containsSys = true + if group == "system:masters" { // Cluster Admin Role + return &api.AccessEntry{ + PrincipalARN: api.MustParseARN(cme.ARN()), + Type: "STANDARD", + AccessPolicies: []api.AccessPolicy{ + { + PolicyARN: api.MustParseARN("arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"), + AccessScope: api.AccessScope{ + Type: ekstypes.AccessScopeTypeCluster, + }, + }, + }, + KubernetesUsername: cme.Username(), + } + } + } + } + + if containsSys { // Check if any GroupName start with "system:"" in name + logger.Warning("at least one group name associated with %q starts with \"system:\", can not create access entry, skipping", cme.ARN()) + return nil + } + + return &api.AccessEntry{ + PrincipalARN: api.MustParseARN(cme.ARN()), + Type: "STANDARD", + KubernetesGroups: cme.Groups(), + KubernetesUsername: cme.Username(), + } + +} + +func doDeleteAWSAuthConfigMap(ctx context.Context, clientset kubernetes.Interface, namespace, name string) error { + logger.Info("deleting %q ConfigMap as it is no longer needed in API mode", name) + return clientset.CoreV1().ConfigMaps(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} diff --git a/pkg/actions/accessentry/migrator_test.go b/pkg/actions/accessentry/migrator_test.go new file mode 100644 index 0000000000..0a8cb5c888 --- /dev/null +++ b/pkg/actions/accessentry/migrator_test.go @@ -0,0 +1,425 @@ +package accessentry_test + +import ( + "bytes" + "context" + "encoding/base32" + "fmt" + "os" + "strings" + + "github.com/kris-nova/logger" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + "gopkg.in/yaml.v2" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + + "github.com/aws/aws-sdk-go-v2/aws" + ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" + awsiam "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + + "github.com/weaveworks/eksctl/pkg/actions/accessentry" + "github.com/weaveworks/eksctl/pkg/actions/accessentry/fakes" + "github.com/weaveworks/eksctl/pkg/authconfigmap" + "github.com/weaveworks/eksctl/pkg/cfn/builder" + "github.com/weaveworks/eksctl/pkg/iam" + "github.com/weaveworks/eksctl/pkg/testutils/mockprovider" +) + +type migrateToAccessEntryEntry struct { + curAuthMode ekstypes.AuthenticationMode + tgAuthMode ekstypes.AuthenticationMode + mockIAM func(provider *mockprovider.MockProvider) + mockK8s func(clientSet *fake.Clientset) + mockAccessEntries func(getter *fakes.FakeGetterInterface) + validateCustomLoggerOutput func(output string) + options accessentry.MigrationOptions + expectedErr string +} + +var _ = Describe("Migrate Access Entry", func() { + + var ( + migrator *accessentry.Migrator + mockProvider *mockprovider.MockProvider + fakeClientset *fake.Clientset + fakeAECreator accessentry.CreatorInterface + fakeAEGetter accessentry.GetterInterface + clusterName = "test-cluster" + genericErr = fmt.Errorf("ERR") + ) + + DescribeTable("Migrate", func(ae migrateToAccessEntryEntry) { + var s fakes.FakeStackCreator + s.CreateStackStub = func(ctx context.Context, stackName string, r builder.ResourceSetReader, tags map[string]string, parameters map[string]string, errorCh chan error) error { + defer close(errorCh) + prefix := fmt.Sprintf("eksctl-%s-accessentry-", clusterName) + idx := strings.Index(stackName, prefix) + if idx < 0 { + return fmt.Errorf("expected stack name to have prefix %q", prefix) + } + suffix := stackName[idx+len(prefix):] + _, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(suffix) + if err != nil { + return fmt.Errorf("expected stack name to have a base32-encoded suffix: %w", err) + } + return nil + } + + mockProvider = mockprovider.NewMockProvider() + if ae.mockIAM != nil { + ae.mockIAM(mockProvider) + } + + fakeClientset = fake.NewSimpleClientset() + if ae.mockK8s != nil { + ae.mockK8s(fakeClientset) + } + + fakeAECreator = &accessentry.Creator{ClusterName: clusterName} + fakeAEGetter = &fakes.FakeGetterInterface{} + if ae.mockAccessEntries != nil { + ae.mockAccessEntries(fakeAEGetter.(*fakes.FakeGetterInterface)) + } + + output := &bytes.Buffer{} + if ae.validateCustomLoggerOutput != nil { + defer func() { + logger.Writer = os.Stdout + }() + logger.Writer = output + } + + migrator = accessentry.NewMigrator( + clusterName, + mockProvider.MockEKS(), + mockProvider.MockIAM(), + fakeClientset, + fakeAECreator, + fakeAEGetter, + ae.curAuthMode, + ae.tgAuthMode, + ) + + err := migrator.MigrateToAccessEntry(context.Background(), ae.options) + + if ae.expectedErr != "" { + Expect(err).To(MatchError(ContainSubstring(ae.expectedErr))) + return + } + + Expect(err).ToNot(HaveOccurred()) + + if ae.validateCustomLoggerOutput != nil { + ae.validateCustomLoggerOutput(output.String()) + } + }, + Entry("[Validation Error] target authentication mode is CONFIG_MAP", migrateToAccessEntryEntry{ + tgAuthMode: ekstypes.AuthenticationModeConfigMap, + expectedErr: "target authentication mode is invalid", + }), + + Entry("[Validation Error] current authentication mode is API", migrateToAccessEntryEntry{ + curAuthMode: ekstypes.AuthenticationModeApi, + tgAuthMode: ekstypes.AuthenticationModeApi, + validateCustomLoggerOutput: func(output string) { + Expect(output).To(ContainSubstring(fmt.Sprintf("cluster authentication mode is already %s; there is no need to migrate to access entries", ekstypes.AuthenticationModeApi))) + }, + }), + + Entry("[API Error] getting access entries fails", migrateToAccessEntryEntry{ + curAuthMode: ekstypes.AuthenticationModeApiAndConfigMap, + tgAuthMode: ekstypes.AuthenticationModeApi, + mockAccessEntries: func(getter *fakes.FakeGetterInterface) { + getter.GetReturns(nil, genericErr) + }, + expectedErr: "fetching existing access entries", + }), + + Entry("[API Error] getting role fails", migrateToAccessEntryEntry{ + curAuthMode: ekstypes.AuthenticationModeApiAndConfigMap, + tgAuthMode: ekstypes.AuthenticationModeApi, + mockAccessEntries: func(getter *fakes.FakeGetterInterface) { + getter.GetReturns([]accessentry.Summary{}, nil) + }, + mockIAM: func(provider *mockprovider.MockProvider) { + provider.MockIAM(). + On("GetRole", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + Expect(args).To(HaveLen(2)) + Expect(args[1]).To(BeAssignableToTypeOf(&awsiam.GetRoleInput{})) + }). + Return(nil, &iamtypes.NoSuchEntityException{}). + Once() + }, + mockK8s: func(clientSet *fake.Clientset) { + roles := []iam.RoleIdentity{{RoleARN: "arn:aws:iam::111122223333:role/test"}} + rolesBytes, err := yaml.Marshal(roles) + Expect(err).NotTo(HaveOccurred()) + + _, err = clientSet.CoreV1().ConfigMaps(authconfigmap.ObjectNamespace).Create(context.Background(), &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: authconfigmap.ObjectName, + }, + Data: map[string]string{ + "mapRoles": string(rolesBytes), + }, + }, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + }, + expectedErr: fmt.Sprintf("role %q does not exists", "test"), + }), + + Entry("[API Error] getting user fails", migrateToAccessEntryEntry{ + curAuthMode: ekstypes.AuthenticationModeApiAndConfigMap, + tgAuthMode: ekstypes.AuthenticationModeApi, + mockAccessEntries: func(getter *fakes.FakeGetterInterface) { + getter.GetReturns([]accessentry.Summary{}, nil) + }, + mockIAM: func(provider *mockprovider.MockProvider) { + provider.MockIAM(). + On("GetUser", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + Expect(args).To(HaveLen(2)) + Expect(args[1]).To(BeAssignableToTypeOf(&awsiam.GetUserInput{})) + }). + Return(nil, &iamtypes.NoSuchEntityException{}). + Once() + }, + mockK8s: func(clientSet *fake.Clientset) { + users := []iam.UserIdentity{{UserARN: "arn:aws:iam::111122223333:user/test"}} + usersBytes, err := yaml.Marshal(users) + Expect(err).NotTo(HaveOccurred()) + + _, err = clientSet.CoreV1().ConfigMaps(authconfigmap.ObjectNamespace).Create(context.Background(), &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: authconfigmap.ObjectName, + }, + Data: map[string]string{ + "mapUsers": string(usersBytes), + }, + }, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + }, + expectedErr: fmt.Sprintf("user %q does not exists", "test"), + }), + + Entry("[TaskTree] should not switch to API mode if service-linked role iamidentitymapping is found", migrateToAccessEntryEntry{ + curAuthMode: ekstypes.AuthenticationModeApiAndConfigMap, + tgAuthMode: ekstypes.AuthenticationModeApi, + mockAccessEntries: func(getter *fakes.FakeGetterInterface) { + getter.GetReturns([]accessentry.Summary{}, nil) + }, + mockIAM: func(provider *mockprovider.MockProvider) { + provider.MockIAM(). + On("GetRole", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + Expect(args).To(HaveLen(2)) + Expect(args[1]).To(BeAssignableToTypeOf(&awsiam.GetRoleInput{})) + }). + Return(&awsiam.GetRoleOutput{ + Role: &iamtypes.Role{ + Arn: aws.String("arn:aws:iam::111122223333:role/aws-service-role/test"), + }, + }, nil). + Once() + }, + mockK8s: func(clientSet *fake.Clientset) { + roles := []iam.RoleIdentity{{RoleARN: "arn:aws:iam::111122223333:role/aws-service-role/test"}} + rolesBytes, err := yaml.Marshal(roles) + Expect(err).NotTo(HaveOccurred()) + + _, err = clientSet.CoreV1().ConfigMaps(authconfigmap.ObjectNamespace).Create(context.Background(), &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: authconfigmap.ObjectName, + }, + Data: map[string]string{ + "mapRoles": string(rolesBytes), + }, + }, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + }, + validateCustomLoggerOutput: func(output string) { + Expect(output).To(ContainSubstring("found service-linked role iamidentitymapping")) + Expect(output).NotTo(ContainSubstring("update authentication mode from API_AND_CONFIG_MAP to API")) + Expect(output).NotTo(ContainSubstring("delete aws-auth configMap when authentication mode is API")) + }, + }), + + Entry("[TaskTree] should not switch to API mode if iamidentitymapping with non-master `system:` group is found", migrateToAccessEntryEntry{ + curAuthMode: ekstypes.AuthenticationModeApiAndConfigMap, + tgAuthMode: ekstypes.AuthenticationModeApi, + mockAccessEntries: func(getter *fakes.FakeGetterInterface) { + getter.GetReturns([]accessentry.Summary{}, nil) + }, + mockIAM: func(provider *mockprovider.MockProvider) { + provider.MockIAM(). + On("GetRole", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + Expect(args).To(HaveLen(2)) + Expect(args[1]).To(BeAssignableToTypeOf(&awsiam.GetRoleInput{})) + }). + Return(&awsiam.GetRoleOutput{ + Role: &iamtypes.Role{ + Arn: aws.String("arn:aws:iam::111122223333:role/test"), + }, + }, nil). + Once() + }, + mockK8s: func(clientSet *fake.Clientset) { + roles := []iam.RoleIdentity{ + { + RoleARN: "arn:aws:iam::111122223333:role/test", + KubernetesIdentity: iam.KubernetesIdentity{ + KubernetesGroups: []string{"system:tests"}, + }, + }, + } + rolesBytes, err := yaml.Marshal(roles) + Expect(err).NotTo(HaveOccurred()) + + _, err = clientSet.CoreV1().ConfigMaps(authconfigmap.ObjectNamespace).Create(context.Background(), &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: authconfigmap.ObjectName, + }, + Data: map[string]string{ + "mapRoles": string(rolesBytes), + }, + }, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + }, + validateCustomLoggerOutput: func(output string) { + Expect(output).To(ContainSubstring("at least one group name associated with %q starts with \"system:\"", "arn:aws:iam::111122223333:role/test")) + Expect(output).NotTo(ContainSubstring("update authentication mode from API_AND_CONFIG_MAP to API")) + Expect(output).NotTo(ContainSubstring("delete aws-auth configMap when authentication mode is API")) + }, + }), + + Entry("[TaskTree] should not switch to API mode if account iamidentitymapping is found", migrateToAccessEntryEntry{ + curAuthMode: ekstypes.AuthenticationModeApiAndConfigMap, + tgAuthMode: ekstypes.AuthenticationModeApi, + mockAccessEntries: func(getter *fakes.FakeGetterInterface) { + getter.GetReturns([]accessentry.Summary{}, nil) + }, + mockK8s: func(clientSet *fake.Clientset) { + accounts := []string{"test-account"} + accountsBytes, err := yaml.Marshal(accounts) + Expect(err).NotTo(HaveOccurred()) + + _, err = clientSet.CoreV1().ConfigMaps(authconfigmap.ObjectNamespace).Create(context.Background(), &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: authconfigmap.ObjectName, + }, + Data: map[string]string{ + "mapAccounts": string(accountsBytes), + }, + }, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + }, + validateCustomLoggerOutput: func(output string) { + Expect(output).To(ContainSubstring("found account iamidentitymapping")) + Expect(output).NotTo(ContainSubstring("update authentication mode from API_AND_CONFIG_MAP to API")) + Expect(output).NotTo(ContainSubstring("delete aws-auth configMap when authentication mode is API")) + }, + }), + + Entry("[TaskTree] should contain all expected tasks", migrateToAccessEntryEntry{ + curAuthMode: ekstypes.AuthenticationModeConfigMap, + tgAuthMode: ekstypes.AuthenticationModeApi, + mockAccessEntries: func(getter *fakes.FakeGetterInterface) { + getter.GetReturns([]accessentry.Summary{ + { + PrincipalARN: "arn:aws:iam::111122223333:role/eksctl-test-cluster-nodegroup-NodeInstanceRole-1", + }, + }, nil) + }, + mockIAM: func(provider *mockprovider.MockProvider) { + provider.MockIAM(). + On("GetRole", mock.Anything, &awsiam.GetRoleInput{ + RoleName: aws.String("eksctl-test-cluster-nodegroup-NodeInstanceRole-1"), + }). + Return(&awsiam.GetRoleOutput{ + Role: &iamtypes.Role{ + Arn: aws.String("arn:aws:iam::111122223333:role/eksctl-test-cluster-nodegroup-NodeInstanceRole-1"), + }, + }, nil) + provider.MockIAM(). + On("GetRole", mock.Anything, &awsiam.GetRoleInput{ + RoleName: aws.String("eksctl-test-cluster-nodegroup-NodeInstanceRole-2"), + }). + Return(&awsiam.GetRoleOutput{ + Role: &iamtypes.Role{ + Arn: aws.String("arn:aws:iam::111122223333:role/eksctl-test-cluster-nodegroup-NodeInstanceRole-2"), + }, + }, nil) + provider.MockIAM(). + On("GetUser", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + Expect(args).To(HaveLen(2)) + Expect(args[1]).To(BeAssignableToTypeOf(&awsiam.GetUserInput{})) + }). + Return(&awsiam.GetUserOutput{ + User: &iamtypes.User{ + Arn: aws.String("arn:aws:iam::111122223333:user/admin"), + }, + }, nil). + Once() + }, + mockK8s: func(clientSet *fake.Clientset) { + roles := []iam.RoleIdentity{ + { + RoleARN: "arn:aws:iam::111122223333:role/eksctl-test-cluster-nodegroup-NodeInstanceRole-1", + KubernetesIdentity: iam.KubernetesIdentity{ + KubernetesUsername: "system:node:{{EC2PrivateDNSName}}", + KubernetesGroups: []string{"system:nodes", "system:bootstrappers"}, + }, + }, + { + RoleARN: "arn:aws:iam::111122223333:role/eksctl-test-cluster-nodegroup-NodeInstanceRole-2", + KubernetesIdentity: iam.KubernetesIdentity{ + KubernetesUsername: "system:node:{{EC2PrivateDNSName}}", + KubernetesGroups: []string{"system:nodes", "system:bootstrappers"}, + }, + }, + } + users := []iam.UserIdentity{ + { + UserARN: "arn:aws:iam::111122223333:user/admin", + KubernetesIdentity: iam.KubernetesIdentity{ + KubernetesUsername: "admin", + KubernetesGroups: []string{"system:masters"}, + }, + }, + } + rolesBytes, err := yaml.Marshal(roles) + Expect(err).NotTo(HaveOccurred()) + usersBytes, err := yaml.Marshal(users) + Expect(err).NotTo(HaveOccurred()) + + _, err = clientSet.CoreV1().ConfigMaps(authconfigmap.ObjectNamespace).Create(context.Background(), &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: authconfigmap.ObjectName, + }, + Data: map[string]string{ + "mapRoles": string(rolesBytes), + "mapUsers": string(usersBytes), + }, + }, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + }, + validateCustomLoggerOutput: func(output string) { + Expect(output).To(ContainSubstring("create access entry for principal ARN arn:aws:iam::111122223333:user/admin")) + Expect(output).To(ContainSubstring("create access entry for principal ARN arn:aws:iam::111122223333:role/eksctl-test-cluster-nodegroup-NodeInstanceRole-2")) + Expect(output).To(ContainSubstring("update authentication mode from CONFIG_MAP to API_AND_CONFIG_MAP")) + Expect(output).To(ContainSubstring("update authentication mode from API_AND_CONFIG_MAP to API")) + // filter out existing access entries + Expect(output).NotTo(ContainSubstring("create access entry for principal ARN arn:aws:iam::111122223333:role/eksctl-test-cluster-nodegroup-NodeInstanceRole-1")) + }, + }), + ) +}) diff --git a/pkg/actions/accessentry/task.go b/pkg/actions/accessentry/task.go index 29596f3993..7860c0faf9 100644 --- a/pkg/actions/accessentry/task.go +++ b/pkg/actions/accessentry/task.go @@ -14,6 +14,7 @@ import ( api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" "github.com/weaveworks/eksctl/pkg/awsapi" "github.com/weaveworks/eksctl/pkg/cfn/builder" + "github.com/weaveworks/eksctl/pkg/utils/tasks" ) //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate @@ -127,3 +128,22 @@ func (t *deleteOwnedAccessEntryTask) Do(errorCh chan error) error { return nil } + +func runAllTasks(taskTree *tasks.TaskTree) error { + logger.Info(taskTree.Describe()) + if errs := taskTree.DoAllSync(); len(errs) > 0 { + var allErrs []string + for _, err := range errs { + allErrs = append(allErrs, err.Error()) + } + return fmt.Errorf(strings.Join(allErrs, "\n")) + } + completedAction := func() string { + if taskTree.PlanMode { + return "skipped" + } + return "completed successfully" + } + logger.Info("all tasks were %s", completedAction()) + return nil +} diff --git a/pkg/apis/eksctl.io/v1alpha5/zz_generated.defaults.go b/pkg/apis/eksctl.io/v1alpha5/zz_generated.defaults.go new file mode 100644 index 0000000000..059ad74d1d --- /dev/null +++ b/pkg/apis/eksctl.io/v1alpha5/zz_generated.defaults.go @@ -0,0 +1,17 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v1alpha5 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + return nil +} diff --git a/pkg/ctl/utils/migrate_to_access_entry.go b/pkg/ctl/utils/migrate_to_access_entry.go new file mode 100644 index 0000000000..31022eed78 --- /dev/null +++ b/pkg/ctl/utils/migrate_to_access_entry.go @@ -0,0 +1,82 @@ +package utils + +import ( + "context" + + ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + accessentryactions "github.com/weaveworks/eksctl/pkg/actions/accessentry" + api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" + "github.com/weaveworks/eksctl/pkg/ctl/cmdutils" +) + +func migrateAccessEntryCmd(cmd *cmdutils.Cmd) { + cfg := api.NewClusterConfig() + cmd.ClusterConfig = cfg + + cmd.SetDescription("migrate-to-access-entry", "Migrates aws-auth to API authentication mode for the cluster", "") + + var options accessentryactions.MigrationOptions + cmd.FlagSetGroup.InFlagSet("Migrate to Access Entry", func(fs *pflag.FlagSet) { + fs.StringVar(&options.TargetAuthMode, "target-authentication-mode", "API_AND_CONFIG_MAP", "Target Authentication mode of migration") + }) + + cmd.FlagSetGroup.InFlagSet("General", func(fs *pflag.FlagSet) { + cmdutils.AddClusterFlag(fs, cmd.ClusterConfig.Metadata) + cmdutils.AddRegionFlag(fs, &cmd.ProviderConfig) + cmdutils.AddTimeoutFlag(fs, &options.Timeout) + cmdutils.AddApproveFlag(fs, cmd) + }) + + cmd.CobraCommand.RunE = func(_ *cobra.Command, args []string) error { + cmd.NameArg = cmdutils.GetNameArg(args) + options.Approve = !cmd.Plan + return doMigrateToAccessEntry(cmd, options) + } +} + +func doMigrateToAccessEntry(cmd *cmdutils.Cmd, options accessentryactions.MigrationOptions) error { + cfg := cmd.ClusterConfig + if cfg.Metadata.Name == "" { + return cmdutils.ErrMustBeSet(cmdutils.ClusterNameFlag(cmd)) + } + + ctx := context.Background() + ctl, err := cmd.NewProviderForExistingCluster(ctx) + if err != nil { + return err + } + + if ok, err := ctl.CanOperate(cfg); !ok { + return err + } + + clientSet, err := ctl.NewStdClientSet(cfg) + if err != nil { + return err + } + + stackManager := ctl.NewStackManager(cfg) + aeCreator := &accessentryactions.Creator{ + ClusterName: cmd.ClusterConfig.Metadata.Name, + StackCreator: stackManager, + } + aeGetter := accessentryactions.NewGetter(cfg.Metadata.Name, ctl.AWSProvider.EKS()) + + if err := accessentryactions.NewMigrator( + cfg.Metadata.Name, + ctl.AWSProvider.EKS(), + ctl.AWSProvider.IAM(), + clientSet, + aeCreator, + aeGetter, + ctl.GetClusterState().AccessConfig.AuthenticationMode, + ekstypes.AuthenticationMode(options.TargetAuthMode), + ).MigrateToAccessEntry(ctx, options); err != nil { + return err + } + + cmdutils.LogPlanModeWarning(cmd.Plan) + return nil +} diff --git a/pkg/ctl/utils/utils.go b/pkg/ctl/utils/utils.go index b9eccaaa98..f677db4c7e 100644 --- a/pkg/ctl/utils/utils.go +++ b/pkg/ctl/utils/utils.go @@ -29,6 +29,7 @@ func Command(flagGrouping *cmdutils.FlagGrouping) *cobra.Command { cmdutils.AddResourceCmd(flagGrouping, verbCmd, describeAddonVersionsCmd) cmdutils.AddResourceCmd(flagGrouping, verbCmd, describeAddonConfigurationCmd) cmdutils.AddResourceCmd(flagGrouping, verbCmd, migrateToPodIdentityCmd) + cmdutils.AddResourceCmd(flagGrouping, verbCmd, migrateAccessEntryCmd) return verbCmd } diff --git a/pkg/iam/mapping.go b/pkg/iam/mapping.go index 526c1f1621..753e96938b 100644 --- a/pkg/iam/mapping.go +++ b/pkg/iam/mapping.go @@ -61,26 +61,26 @@ func CompareIdentity(a, b Identity) bool { // KubernetesIdentity represents a kubernetes identity to be used in iam mappings type KubernetesIdentity struct { - KubernetesUsername string `json:"username,omitempty"` - KubernetesGroups []string `json:"groups,omitempty"` + KubernetesUsername string `json:"username,omitempty" yaml:"username,omitempty"` + KubernetesGroups []string `json:"groups,omitempty" yaml:"groups,omitempty"` } // UserIdentity represents a mapping from an IAM user to a kubernetes identity type UserIdentity struct { - UserARN string `json:"userarn,omitempty"` - KubernetesIdentity + UserARN string `json:"userarn,omitempty"` + KubernetesIdentity `yaml:",inline"` } // RoleIdentity represents a mapping from an IAM role to a kubernetes identity type RoleIdentity struct { - RoleARN string `json:"rolearn,omitempty"` - KubernetesIdentity + RoleARN string `json:"rolearn,omitempty"` + KubernetesIdentity `yaml:",inline"` } // AccountIdentity represents a mapping from an IAM role to a kubernetes identity type AccountIdentity struct { - KubernetesAccount string `json:"account,omitempty"` - KubernetesIdentity + KubernetesAccount string `json:"account,omitempty" yaml:"account,omitempty"` + KubernetesIdentity `yaml:",inline"` } // ARN returns the ARN of the iam mapping diff --git a/userdocs/src/usage/access-entries.md b/userdocs/src/usage/access-entries.md index efac8fab29..4c652b5474 100644 --- a/userdocs/src/usage/access-entries.md +++ b/userdocs/src/usage/access-entries.md @@ -148,6 +148,25 @@ accessEntry: eksctl delete accessentry -f config.yaml ``` +### Migrate IAM identity mappings to access entries + +The user can migrate their existing IAM identities from `aws-auth` configmap to access entries by running the following: + +```shell +eksctl utils migrate-to-access-entry --cluster my-cluster --target-authentication-mode +``` + +When `--target-authentication-mode` flag is set to `API`, authentication mode is switched to `API` mode (skipped if already in `API` mode), IAM identity mappings will be migrated to access entries, and `aws-auth` configmap is deleted from the cluster. + +When `--target-authentication-mode` flag is set to `API_AND_CONFIG_MAP`, authentication mode is switched to `API_AND_CONFIG_MAP` mode (skipped if already in `API_AND_CONFIG_MAP` mode), IAM identity mappings will be migrated to access entries, but `aws-auth` configmap is preserved. + +???+ note + When `--target-authentication-mode` flag is set to `API`, this command will not update authentication mode to `API` mode if `aws-auth` configmap has one of the below constraints. + + * There is an Account level identity mapping. + * One or more Roles/Users are mapped to the kubernetes group(s) which begin with prefix `system:` (except for EKS specific groups i.e. `system:masters`, `system:bootstrappers`, `system:nodes` etc). + * One or more IAM identity mapping(s) are for a [Service Linked Role](https://docs.aws.amazon.com/IAM/latest/UserGuide/using-service-linked-roles.html). + ## Disabling cluster creator admin permissions `eksctl` has added a new field `accessConfig.bootstrapClusterCreatorAdminPermissions: boolean` that, when set to false, disables granting cluster-admin permissions to the IAM identity creating the cluster. i.e. diff --git a/userdocs/src/usage/minimum-iam-policies.md b/userdocs/src/usage/minimum-iam-policies.md index 7b8dd35ec1..dfdc7c4c3d 100644 --- a/userdocs/src/usage/minimum-iam-policies.md +++ b/userdocs/src/usage/minimum-iam-policies.md @@ -157,10 +157,12 @@ IamLimitedAccess { "Effect": "Allow", "Action": [ - "iam:GetRole" + "iam:GetRole", + "iam:GetUser" ], "Resource": [ - "arn:aws:iam:::role/*" + "arn:aws:iam:::role/*", + "arn:aws:iam:::user/*" ] }, {