From d6be19f9177115a3b7da9e4a016646b9dbdcc8b3 Mon Sep 17 00:00:00 2001 From: Josh French Date: Mon, 6 Oct 2025 10:57:05 -0400 Subject: [PATCH] Add Access Entry support Co-authored-by: Adam Malcontenti-Wilson --- ...ster.x-k8s.io_awsmanagedcontrolplanes.yaml | 79 ++ ...8s.io_awsmanagedcontrolplanetemplates.yaml | 81 ++ controlplane/eks/api/v1beta1/conversion.go | 1 + .../api/v1beta1/zz_generated.conversion.go | 1 + .../v1beta2/awsmanagedcontrolplane_types.go | 58 ++ .../v1beta2/awsmanagedcontrolplane_webhook.go | 57 ++ .../awsmanagedcontrolplane_webhook_test.go | 163 ++++ controlplane/eks/api/v1beta2/types.go | 26 + .../eks/api/v1beta2/zz_generated.deepcopy.go | 70 ++ pkg/cloud/services/eks/accessentry.go | 280 ++++++ pkg/cloud/services/eks/accessentry_test.go | 799 ++++++++++++++++++ pkg/cloud/services/eks/cluster.go | 4 + .../services/eks/mock_eksiface/eksapi_mock.go | 160 ++++ pkg/cloud/services/eks/service.go | 8 + ...control-plane-only-with-accessentries.yaml | 51 ++ .../suites/managed/eks_access_entries_test.go | 147 ++++ test/e2e/suites/managed/helpers.go | 68 ++ 17 files changed, 2053 insertions(+) create mode 100644 pkg/cloud/services/eks/accessentry.go create mode 100644 pkg/cloud/services/eks/accessentry_test.go create mode 100644 test/e2e/data/eks/cluster-template-eks-control-plane-only-with-accessentries.yaml create mode 100644 test/e2e/suites/managed/eks_access_entries_test.go diff --git a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml index 858dac8a4c..68f213edf9 100644 --- a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml +++ b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml @@ -2292,6 +2292,85 @@ spec: ignored when updating existing clusters. Defaults to true. type: boolean type: object + accessEntries: + description: |- + AccessEntries specifies the access entries for the cluster + Access entries require AuthenticationMode to be either "api" or "api_and_config_map" + items: + description: AccessEntry represents an AWS EKS access entry for + IAM principals + properties: + accessPolicies: + description: |- + AccessPolicies specifies the policies to associate with this access entry + Cannot be specified if Type is "ec2_linux" or "ec2_windows" + items: + description: AccessPolicyReference represents a reference + to an AWS EKS access policy + properties: + accessScope: + description: AccessScope specifies the scope for the policy + properties: + namespaces: + description: |- + Namespaces are the namespaces for the access scope + Only valid when Type is namespace + items: + type: string + minItems: 1 + type: array + type: + default: cluster + description: Type is the type of access scope. Defaults + to "cluster". + enum: + - cluster + - namespace + type: string + required: + - type + type: object + policyARN: + description: PolicyARN is the Amazon Resource Name (ARN) + of the access policy + type: string + required: + - accessScope + - policyARN + type: object + maxItems: 20 + type: array + kubernetesGroups: + description: |- + KubernetesGroups represents the Kubernetes groups for the access entry + Cannot be specified if Type is "ec2_linux" or "ec2_windows" + items: + type: string + type: array + principalARN: + description: PrincipalARN is the Amazon Resource Name (ARN) + of the IAM principal + type: string + type: + default: standard + description: Type is the type of access entry. Defaults to standard + if not specified. + enum: + - standard + - ec2_linux + - ec2_windows + - fargate_linux + - ec2 + - hybrid_linux + - hyperpod_linux + type: string + username: + description: Username is the username for the access entry + type: string + required: + - principalARN + type: object + type: array additionalTags: additionalProperties: type: string diff --git a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanetemplates.yaml b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanetemplates.yaml index ad5c56c54b..2fa0aa788b 100644 --- a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanetemplates.yaml +++ b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanetemplates.yaml @@ -75,6 +75,87 @@ spec: ignored when updating existing clusters. Defaults to true. type: boolean type: object + accessEntries: + description: |- + AccessEntries specifies the access entries for the cluster + Access entries require AuthenticationMode to be either "api" or "api_and_config_map" + items: + description: AccessEntry represents an AWS EKS access entry + for IAM principals + properties: + accessPolicies: + description: |- + AccessPolicies specifies the policies to associate with this access entry + Cannot be specified if Type is "ec2_linux" or "ec2_windows" + items: + description: AccessPolicyReference represents a reference + to an AWS EKS access policy + properties: + accessScope: + description: AccessScope specifies the scope for + the policy + properties: + namespaces: + description: |- + Namespaces are the namespaces for the access scope + Only valid when Type is namespace + items: + type: string + minItems: 1 + type: array + type: + default: cluster + description: Type is the type of access scope. + Defaults to "cluster". + enum: + - cluster + - namespace + type: string + required: + - type + type: object + policyARN: + description: PolicyARN is the Amazon Resource + Name (ARN) of the access policy + type: string + required: + - accessScope + - policyARN + type: object + maxItems: 20 + type: array + kubernetesGroups: + description: |- + KubernetesGroups represents the Kubernetes groups for the access entry + Cannot be specified if Type is "ec2_linux" or "ec2_windows" + items: + type: string + type: array + principalARN: + description: PrincipalARN is the Amazon Resource Name + (ARN) of the IAM principal + type: string + type: + default: standard + description: Type is the type of access entry. Defaults + to standard if not specified. + enum: + - standard + - ec2_linux + - ec2_windows + - fargate_linux + - ec2 + - hybrid_linux + - hyperpod_linux + type: string + username: + description: Username is the username for the access + entry + type: string + required: + - principalARN + type: object + type: array additionalTags: additionalProperties: type: string diff --git a/controlplane/eks/api/v1beta1/conversion.go b/controlplane/eks/api/v1beta1/conversion.go index 0985ef66d5..6987cdc8ac 100644 --- a/controlplane/eks/api/v1beta1/conversion.go +++ b/controlplane/eks/api/v1beta1/conversion.go @@ -118,6 +118,7 @@ func (r *AWSManagedControlPlane) ConvertTo(dstRaw conversion.Hub) error { dst.Spec.Partition = restored.Spec.Partition dst.Spec.RestrictPrivateSubnets = restored.Spec.RestrictPrivateSubnets dst.Spec.AccessConfig = restored.Spec.AccessConfig + dst.Spec.AccessEntries = restored.Spec.AccessEntries dst.Spec.RolePath = restored.Spec.RolePath dst.Spec.RolePermissionsBoundary = restored.Spec.RolePermissionsBoundary dst.Status.Version = restored.Status.Version diff --git a/controlplane/eks/api/v1beta1/zz_generated.conversion.go b/controlplane/eks/api/v1beta1/zz_generated.conversion.go index 48f326b2dc..fc81e7e090 100644 --- a/controlplane/eks/api/v1beta1/zz_generated.conversion.go +++ b/controlplane/eks/api/v1beta1/zz_generated.conversion.go @@ -376,6 +376,7 @@ func autoConvert_v1beta2_AWSManagedControlPlaneSpec_To_v1beta1_AWSManagedControl out.Addons = (*[]Addon)(unsafe.Pointer(in.Addons)) out.OIDCIdentityProviderConfig = (*OIDCIdentityProviderConfig)(unsafe.Pointer(in.OIDCIdentityProviderConfig)) // WARNING: in.AccessConfig requires manual conversion: does not exist in peer-type + // WARNING: in.AccessEntries requires manual conversion: does not exist in peer-type if err := Convert_v1beta2_VpcCni_To_v1beta1_VpcCni(&in.VpcCni, &out.VpcCni, s); err != nil { return err } diff --git a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_types.go b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_types.go index 9112863e35..f46e0324bd 100644 --- a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_types.go +++ b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_types.go @@ -196,6 +196,11 @@ type AWSManagedControlPlaneSpec struct { //nolint: maligned // +optional AccessConfig *AccessConfig `json:"accessConfig,omitempty"` + // AccessEntries specifies the access entries for the cluster + // Access entries require AuthenticationMode to be either "api" or "api_and_config_map" + // +optional + AccessEntries []AccessEntry `json:"accessEntries,omitempty"` + // VpcCni is used to set configuration options for the VPC CNI plugin // +optional VpcCni VpcCni `json:"vpcCni,omitempty"` @@ -267,6 +272,59 @@ type AccessConfig struct { BootstrapClusterCreatorAdminPermissions *bool `json:"bootstrapClusterCreatorAdminPermissions,omitempty"` } +// AccessEntry represents an AWS EKS access entry for IAM principals +type AccessEntry struct { + // PrincipalARN is the Amazon Resource Name (ARN) of the IAM principal + // +kubebuilder:validation:Required + PrincipalARN string `json:"principalARN"` + + // Type is the type of access entry. Defaults to standard if not specified. + // +kubebuilder:default=standard + // +kubebuilder:validation:Enum=standard;ec2_linux;ec2_windows;fargate_linux;ec2;hybrid_linux;hyperpod_linux + // +optional + Type AccessEntryType `json:"type,omitempty"` + + // KubernetesGroups represents the Kubernetes groups for the access entry + // Cannot be specified if Type is "ec2_linux" or "ec2_windows" + // +optional + KubernetesGroups []string `json:"kubernetesGroups,omitempty"` + + // Username is the username for the access entry + // +optional + Username string `json:"username,omitempty"` + + // AccessPolicies specifies the policies to associate with this access entry + // Cannot be specified if Type is "ec2_linux" or "ec2_windows" + // +optional + // +kubebuilder:validation:MaxItems=20 + AccessPolicies []AccessPolicyReference `json:"accessPolicies,omitempty"` +} + +// AccessPolicyReference represents a reference to an AWS EKS access policy +type AccessPolicyReference struct { + // PolicyARN is the Amazon Resource Name (ARN) of the access policy + // +kubebuilder:validation:Required + PolicyARN string `json:"policyARN"` + + // AccessScope specifies the scope for the policy + // +kubebuilder:validation:Required + AccessScope AccessScope `json:"accessScope"` +} + +// AccessScope represents the scope for an access policy +type AccessScope struct { + // Type is the type of access scope. Defaults to "cluster". + // +kubebuilder:validation:Enum=cluster;namespace + // +kubebuilder:default=cluster + Type AccessScopeType `json:"type"` + + // Namespaces are the namespaces for the access scope + // Only valid when Type is namespace + // +optional + // +kubebuilder:validation:MinItems=1 + Namespaces []string `json:"namespaces,omitempty"` +} + // EncryptionConfig specifies the encryption configuration for the EKS clsuter. type EncryptionConfig struct { // Provider specifies the ARN or alias of the CMK (in AWS KMS) diff --git a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook.go b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook.go index 5554eff7c1..f4214469b0 100644 --- a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook.go +++ b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook.go @@ -108,6 +108,7 @@ func (*awsManagedControlPlaneWebhook) ValidateCreate(_ context.Context, obj runt allErrs = append(allErrs, r.validateNetwork()...) allErrs = append(allErrs, r.validatePrivateDNSHostnameTypeOnLaunch()...) allErrs = append(allErrs, r.validateAccessConfigCreate()...) + allErrs = append(allErrs, r.validateAccessEntries()...) if len(allErrs) == 0 { return nil, nil @@ -150,6 +151,7 @@ func (*awsManagedControlPlaneWebhook) ValidateUpdate(ctx context.Context, oldObj allErrs = append(allErrs, r.validateKubeProxy()...) allErrs = append(allErrs, r.Spec.AdditionalTags.Validate()...) allErrs = append(allErrs, r.validatePrivateDNSHostnameTypeOnLaunch()...) + allErrs = append(allErrs, r.validateAccessEntries()...) if r.Spec.Region != oldAWSManagedControlplane.Spec.Region { allErrs = append(allErrs, @@ -367,6 +369,61 @@ func (r *AWSManagedControlPlane) validateAccessConfigCreate() field.ErrorList { return allErrs } +func (r *AWSManagedControlPlane) validateAccessEntries() field.ErrorList { + var allErrs field.ErrorList + + if len(r.Spec.AccessEntries) > 0 { + // AccessEntries require AuthenticationMode to be api or api_and_config_map + if r.Spec.AccessConfig == nil || + (r.Spec.AccessConfig.AuthenticationMode != EKSAuthenticationModeAPI && + r.Spec.AccessConfig.AuthenticationMode != EKSAuthenticationModeAPIAndConfigMap) { + allErrs = append(allErrs, + field.Invalid(field.NewPath("spec", "accessEntries"), + r.Spec.AccessEntries, + "accessEntries can only be used when authenticationMode is set to api or api_and_config_map", + ), + ) + } + + for i, entry := range r.Spec.AccessEntries { + // Validate that EC2 types don't have kubernetes groups or access policies + if entry.Type == AccessEntryTypeEC2Linux || entry.Type == AccessEntryTypeEC2Windows { + if len(entry.KubernetesGroups) > 0 { + allErrs = append(allErrs, + field.Invalid(field.NewPath("spec", "accessEntries").Index(i).Child("kubernetesGroups"), + entry.KubernetesGroups, + "kubernetesGroups cannot be specified when type is ec2_linux or ec2_windows", + ), + ) + } + + if len(entry.AccessPolicies) > 0 { + allErrs = append(allErrs, + field.Invalid(field.NewPath("spec", "accessEntries").Index(i).Child("accessPolicies"), + entry.AccessPolicies, + "accessPolicies cannot be specified when type is ec2_linux or ec2_windows", + ), + ) + } + } + + // Validate namespace scopes + for j, policy := range entry.AccessPolicies { + if policy.AccessScope.Type == AccessScopeTypeNamespace && len(policy.AccessScope.Namespaces) == 0 { + allErrs = append(allErrs, + field.Invalid(field.NewPath("spec", "accessEntries").Index(i).Child("accessPolicies").Index(j).Child("accessScope", "namespaces"), + policy.AccessScope.Namespaces, + "at least one value must be provided when accessScope type is namespace", + ), + ) + } + } + } + } + + return allErrs +} + func (r *AWSManagedControlPlane) validateIAMAuthConfig() field.ErrorList { return validateIAMAuthConfig(r.Spec.IAMAuthenticatorConfig, field.NewPath("spec.iamAuthenticatorConfig")) } diff --git a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook_test.go b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook_test.go index 40de7b369b..f94480580f 100644 --- a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook_test.go +++ b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook_test.go @@ -1044,3 +1044,166 @@ func TestValidatingWebhookUpdateSecondaryCidr(t *testing.T) { }) } } + +func TestWebhookValidateAccessEntries(t *testing.T) { + tests := []struct { + name string + accessConfig *AccessConfig + accessEntries []AccessEntry + expectError bool + errorSubstr string + }{ + { + name: "valid access entries with api auth mode", + accessConfig: &AccessConfig{ + AuthenticationMode: EKSAuthenticationModeAPI, + }, + accessEntries: []AccessEntry{ + { + PrincipalARN: "arn:aws:iam::123456789012:role/EKSAdmin", + Type: AccessEntryTypeStandard, + KubernetesGroups: []string{"system:masters"}, + }, + }, + expectError: false, + }, + { + name: "valid access entries with api_and_config_map auth mode", + accessConfig: &AccessConfig{ + AuthenticationMode: EKSAuthenticationModeAPIAndConfigMap, + }, + accessEntries: []AccessEntry{ + { + PrincipalARN: "arn:aws:iam::123456789012:role/EKSAdmin", + Type: AccessEntryTypeStandard, + KubernetesGroups: []string{"system:masters"}, + }, + }, + expectError: false, + }, + { + name: "invalid access entries with config_map auth mode", + accessConfig: &AccessConfig{ + AuthenticationMode: EKSAuthenticationModeConfigMap, + }, + accessEntries: []AccessEntry{ + { + PrincipalARN: "arn:aws:iam::123456789012:role/EKSAdmin", + Type: AccessEntryTypeStandard, + KubernetesGroups: []string{"system:masters"}, + }, + }, + expectError: true, + errorSubstr: "accessEntries can only be used when authenticationMode is set to api or api_and_config_map", + }, + { + name: "invalid ec2_linux access entry with kubernetes groups", + accessConfig: &AccessConfig{ + AuthenticationMode: EKSAuthenticationModeAPI, + }, + accessEntries: []AccessEntry{ + { + PrincipalARN: "arn:aws:iam::123456789012:role/EKSAdmin", + Type: AccessEntryTypeEC2Linux, + KubernetesGroups: []string{"system:masters"}, + }, + }, + expectError: true, + errorSubstr: "kubernetesGroups cannot be specified when type is ec2_linux or ec2_windows", + }, + { + name: "invalid ec2_windows access entry with access policies", + accessConfig: &AccessConfig{ + AuthenticationMode: EKSAuthenticationModeAPI, + }, + accessEntries: []AccessEntry{ + { + PrincipalARN: "arn:aws:iam::123456789012:role/EKSAdmin", + Type: AccessEntryTypeEC2Windows, + AccessPolicies: []AccessPolicyReference{ + { + PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", + AccessScope: AccessScope{ + Type: AccessScopeTypeCluster, + }, + }, + }, + }, + }, + expectError: true, + errorSubstr: "accessPolicies cannot be specified when type is ec2_linux or ec2_windows", + }, + { + name: "invalid access policy with namespace type and no namespaces", + accessConfig: &AccessConfig{ + AuthenticationMode: EKSAuthenticationModeAPI, + }, + accessEntries: []AccessEntry{ + { + PrincipalARN: "arn:aws:iam::123456789012:role/EKSAdmin", + Type: AccessEntryTypeStandard, + AccessPolicies: []AccessPolicyReference{ + { + PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", + AccessScope: AccessScope{ + Type: AccessScopeTypeNamespace, + }, + }, + }, + }, + }, + expectError: true, + errorSubstr: "at least one value must be provided when accessScope type is namespace", + }, + { + name: "valid access policy with namespace type and namespaces", + accessConfig: &AccessConfig{ + AuthenticationMode: EKSAuthenticationModeAPI, + }, + accessEntries: []AccessEntry{ + { + PrincipalARN: "arn:aws:iam::123456789012:role/EKSAdmin", + Type: AccessEntryTypeStandard, + AccessPolicies: []AccessPolicyReference{ + { + PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", + AccessScope: AccessScope{ + Type: AccessScopeTypeNamespace, + Namespaces: []string{"default", "kube-system"}, + }, + }, + }, + }, + }, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + mcp := &AWSManagedControlPlane{ + Spec: AWSManagedControlPlaneSpec{ + EKSClusterName: "default_cluster1", + AccessConfig: tc.accessConfig, + AccessEntries: tc.accessEntries, + }, + } + + warn, err := (&awsManagedControlPlaneWebhook{}).ValidateCreate(context.Background(), mcp) + + if tc.expectError { + g.Expect(err).ToNot(BeNil()) + if tc.errorSubstr != "" { + g.Expect(err.Error()).To(ContainSubstring(tc.errorSubstr)) + } + } else { + g.Expect(err).To(BeNil()) + } + // Nothing emits warnings yet + g.Expect(warn).To(BeEmpty()) + }) + } +} + diff --git a/controlplane/eks/api/v1beta2/types.go b/controlplane/eks/api/v1beta2/types.go index 79f58f8e77..599155f484 100644 --- a/controlplane/eks/api/v1beta2/types.go +++ b/controlplane/eks/api/v1beta2/types.go @@ -100,6 +100,32 @@ var ( EKSAuthenticationModeAPIAndConfigMap = EKSAuthenticationMode("api_and_config_map") ) +// AccessEntryType defines the type of an access entry +type AccessEntryType string + +func (a AccessEntryType) APIValue() *string { + v := strings.ToUpper(string(a)) + return &v +} + +var ( + AccessEntryTypeStandard = AccessEntryType("standard") + AccessEntryTypeEC2Linux = AccessEntryType("ec2_linux") + AccessEntryTypeEC2Windows = AccessEntryType("ec2_windows") + AccessEntryTypeFargateLinux = AccessEntryType("fargate_linux") + AccessEntryTypeEC2 = AccessEntryType("ec2") + AccessEntryTypeHybridLinux = AccessEntryType("hybrid_linux") + AccessEntryTypeHyperpodLinux = AccessEntryType("hyperpod_linux") +) + +// AccessScopeType defines the scope type for an access policy +type AccessScopeType string + +var ( + AccessScopeTypeCluster = AccessScopeType("cluster") + AccessScopeTypeNamespace = AccessScopeType("namespace") +) + var ( // DefaultEKSControlPlaneRole is the name of the default IAM role to use for the EKS control plane // if no other role is supplied in the spec and if iam role creation is not enabled. The default diff --git a/controlplane/eks/api/v1beta2/zz_generated.deepcopy.go b/controlplane/eks/api/v1beta2/zz_generated.deepcopy.go index 678a641e9c..6e55a595b0 100644 --- a/controlplane/eks/api/v1beta2/zz_generated.deepcopy.go +++ b/controlplane/eks/api/v1beta2/zz_generated.deepcopy.go @@ -175,6 +175,13 @@ func (in *AWSManagedControlPlaneSpec) DeepCopyInto(out *AWSManagedControlPlaneSp *out = new(AccessConfig) (*in).DeepCopyInto(*out) } + if in.AccessEntries != nil { + in, out := &in.AccessEntries, &out.AccessEntries + *out = make([]AccessEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } in.VpcCni.DeepCopyInto(&out.VpcCni) out.KubeProxy = in.KubeProxy } @@ -358,6 +365,69 @@ func (in *AccessConfig) DeepCopy() *AccessConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccessEntry) DeepCopyInto(out *AccessEntry) { + *out = *in + if in.KubernetesGroups != nil { + in, out := &in.KubernetesGroups, &out.KubernetesGroups + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AccessPolicies != nil { + in, out := &in.AccessPolicies, &out.AccessPolicies + *out = make([]AccessPolicyReference, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessEntry. +func (in *AccessEntry) DeepCopy() *AccessEntry { + if in == nil { + return nil + } + out := new(AccessEntry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccessPolicyReference) DeepCopyInto(out *AccessPolicyReference) { + *out = *in + in.AccessScope.DeepCopyInto(&out.AccessScope) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessPolicyReference. +func (in *AccessPolicyReference) DeepCopy() *AccessPolicyReference { + if in == nil { + return nil + } + out := new(AccessPolicyReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccessScope) DeepCopyInto(out *AccessScope) { + *out = *in + if in.Namespaces != nil { + in, out := &in.Namespaces, &out.Namespaces + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessScope. +func (in *AccessScope) DeepCopy() *AccessScope { + if in == nil { + return nil + } + out := new(AccessScope) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Addon) DeepCopyInto(out *Addon) { *out = *in diff --git a/pkg/cloud/services/eks/accessentry.go b/pkg/cloud/services/eks/accessentry.go new file mode 100644 index 0000000000..72d63d28bd --- /dev/null +++ b/pkg/cloud/services/eks/accessentry.go @@ -0,0 +1,280 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eks + +import ( + "context" + "slices" + + "github.com/aws/aws-sdk-go-v2/service/eks" + ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/pkg/errors" + + ekscontrolplanev1 "sigs.k8s.io/cluster-api-provider-aws/v2/controlplane/eks/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/record" +) + +func (s *Service) reconcileAccessEntries(ctx context.Context) error { + if len(s.scope.ControlPlane.Spec.AccessEntries) == 0 { + s.scope.Info("no access entries defined, skipping reconcile") + return nil + } + + if s.scope.ControlPlane.Spec.AccessConfig == nil || + s.scope.ControlPlane.Spec.AccessConfig.AuthenticationMode == "" || + (s.scope.ControlPlane.Spec.AccessConfig.AuthenticationMode != ekscontrolplanev1.EKSAuthenticationModeAPI && + s.scope.ControlPlane.Spec.AccessConfig.AuthenticationMode != ekscontrolplanev1.EKSAuthenticationModeAPIAndConfigMap) { + s.scope.Info("access mode is not api or api_and_config_map, skipping reconcile") + return nil + } + + existingAccessEntries, err := s.getExistingAccessEntries(ctx) + if err != nil { + return errors.Wrap(err, "failed to list existing access entries") + } + + for _, accessEntry := range s.scope.ControlPlane.Spec.AccessEntries { + if _, exists := existingAccessEntries[accessEntry.PrincipalARN]; exists { + if err := s.updateAccessEntry(ctx, accessEntry); err != nil { + return errors.Wrapf(err, "failed to update access entry for principal %s", accessEntry.PrincipalARN) + } + delete(existingAccessEntries, accessEntry.PrincipalARN) + } else { + if err := s.createAccessEntry(ctx, accessEntry); err != nil { + return errors.Wrapf(err, "failed to create access entry for principal %s", accessEntry.PrincipalARN) + } + } + } + + for principalArn := range existingAccessEntries { + if err := s.deleteAccessEntry(ctx, principalArn); err != nil { + return errors.Wrapf(err, "failed to delete access entry for principal %s", principalArn) + } + } + + record.Event(s.scope.ControlPlane, "SuccessfulReconcileAccessEntries", "Reconciled access entries") + return nil +} + +func (s *Service) getExistingAccessEntries(ctx context.Context) (map[string]bool, error) { + existingAccessEntries := make(map[string]bool) + var nextToken *string + + clusterName := s.scope.KubernetesClusterName() + for { + input := &eks.ListAccessEntriesInput{ + ClusterName: &clusterName, + NextToken: nextToken, + } + + output, err := s.EKSClient.ListAccessEntries(ctx, input) + if err != nil { + return nil, errors.Wrap(err, "failed to list access entries") + } + + for _, principalArn := range output.AccessEntries { + existingAccessEntries[principalArn] = true + } + + if output.NextToken == nil { + break + } + + nextToken = output.NextToken + } + + return existingAccessEntries, nil +} + +func (s *Service) createAccessEntry(ctx context.Context, accessEntry ekscontrolplanev1.AccessEntry) error { + clusterName := s.scope.KubernetesClusterName() + createInput := &eks.CreateAccessEntryInput{ + ClusterName: &clusterName, + PrincipalArn: &accessEntry.PrincipalARN, + } + + if len(accessEntry.KubernetesGroups) > 0 { + createInput.KubernetesGroups = accessEntry.KubernetesGroups + } + + if accessEntry.Type != "" { + createInput.Type = accessEntry.Type.APIValue() + } + + if accessEntry.Username != "" { + createInput.Username = &accessEntry.Username + } + + if _, err := s.EKSClient.CreateAccessEntry(ctx, createInput); err != nil { + return errors.Wrapf(err, "failed to create access entry for principal %s", accessEntry.PrincipalARN) + } + + if err := s.reconcileAccessPolicies(ctx, accessEntry); err != nil { + return errors.Wrapf(err, "failed to reconcile access policies for principal %s", accessEntry.PrincipalARN) + } + + return nil +} + +func (s *Service) updateAccessEntry(ctx context.Context, accessEntry ekscontrolplanev1.AccessEntry) error { + clusterName := s.scope.KubernetesClusterName() + describeInput := &eks.DescribeAccessEntryInput{ + ClusterName: &clusterName, + PrincipalArn: &accessEntry.PrincipalARN, + } + + describeOutput, err := s.EKSClient.DescribeAccessEntry(ctx, describeInput) + if err != nil { + return errors.Wrapf(err, "failed to describe access entry for principal %s", accessEntry.PrincipalARN) + } + + // EKS requires recreate when changing type + if *accessEntry.Type.APIValue() != *describeOutput.AccessEntry.Type { + if err = s.deleteAccessEntry(ctx, accessEntry.PrincipalARN); err != nil { + return errors.Wrapf(err, "failed to delete access entry for principal %s during recreation", accessEntry.PrincipalARN) + } + + if err = s.createAccessEntry(ctx, accessEntry); err != nil { + return errors.Wrapf(err, "failed to recreate access entry for principal %s", accessEntry.PrincipalARN) + } + return nil + } + + slices.Sort(accessEntry.KubernetesGroups) + slices.Sort(describeOutput.AccessEntry.KubernetesGroups) + + updateInput := &eks.UpdateAccessEntryInput{ + ClusterName: &clusterName, + PrincipalArn: &accessEntry.PrincipalARN, + } + + needsUpdate := false + + if accessEntry.Username != *describeOutput.AccessEntry.Username { + updateInput.Username = &accessEntry.Username + needsUpdate = true + } + + if !slices.Equal(accessEntry.KubernetesGroups, describeOutput.AccessEntry.KubernetesGroups) { + updateInput.KubernetesGroups = accessEntry.KubernetesGroups + needsUpdate = true + } + + if needsUpdate { + if _, err := s.EKSClient.UpdateAccessEntry(ctx, updateInput); err != nil { + return errors.Wrapf(err, "failed to update access entry for principal %s", accessEntry.PrincipalARN) + } + } + + if err := s.reconcileAccessPolicies(ctx, accessEntry); err != nil { + return errors.Wrapf(err, "failed to reconcile access policies for principal %s", accessEntry.PrincipalARN) + } + + return nil +} + +func (s *Service) deleteAccessEntry(ctx context.Context, principalArn string) error { + clusterName := s.scope.KubernetesClusterName() + + if _, err := s.EKSClient.DeleteAccessEntry(ctx, &eks.DeleteAccessEntryInput{ + ClusterName: &clusterName, + PrincipalArn: &principalArn, + }); err != nil { + return errors.Wrapf(err, "failed to delete access entry for principal %s", principalArn) + } + + return nil +} + +func (s *Service) reconcileAccessPolicies(ctx context.Context, accessEntry ekscontrolplanev1.AccessEntry) error { + if accessEntry.Type == ekscontrolplanev1.AccessEntryTypeEC2Linux || accessEntry.Type == ekscontrolplanev1.AccessEntryTypeEC2Windows { + s.scope.Info("Skipping access policy reconciliation for EC2 access type", "principalARN", accessEntry.PrincipalARN) + return nil + } + + existingPolicies, err := s.getExistingAccessPolicies(ctx, accessEntry.PrincipalARN) + if err != nil { + return errors.Wrapf(err, "failed to get existing access policies for principal %s", accessEntry.PrincipalARN) + } + + clusterName := s.scope.KubernetesClusterName() + + for _, policy := range accessEntry.AccessPolicies { + input := &eks.AssociateAccessPolicyInput{ + ClusterName: &clusterName, + PrincipalArn: &accessEntry.PrincipalARN, + PolicyArn: &policy.PolicyARN, + AccessScope: &ekstypes.AccessScope{ + Type: ekstypes.AccessScopeType(policy.AccessScope.Type), + }, + } + + if policy.AccessScope.Type == "namespace" && len(policy.AccessScope.Namespaces) > 0 { + input.AccessScope.Namespaces = policy.AccessScope.Namespaces + } + + if _, err := s.EKSClient.AssociateAccessPolicy(ctx, input); err != nil { + return errors.Wrapf(err, "failed to associate access policy %s", policy.PolicyARN) + } + + delete(existingPolicies, policy.PolicyARN) + } + + for policyARN := range existingPolicies { + if _, err := s.EKSClient.DisassociateAccessPolicy(ctx, &eks.DisassociateAccessPolicyInput{ + ClusterName: &clusterName, + PrincipalArn: &accessEntry.PrincipalARN, + PolicyArn: &policyARN, + }); err != nil { + return errors.Wrapf(err, "failed to disassociate access policy %s", policyARN) + } + } + + return nil +} + +func (s *Service) getExistingAccessPolicies(ctx context.Context, principalARN string) (map[string]ekstypes.AssociatedAccessPolicy, error) { + existingPolicies := map[string]ekstypes.AssociatedAccessPolicy{} + var nextToken *string + clusterName := s.scope.KubernetesClusterName() + + for { + input := &eks.ListAssociatedAccessPoliciesInput{ + ClusterName: &clusterName, + PrincipalArn: &principalARN, + NextToken: nextToken, + } + + output, err := s.EKSClient.ListAssociatedAccessPolicies(ctx, input) + if err != nil { + return nil, errors.Wrapf(err, "failed to list associated access policies for principal %s", principalARN) + } + + for _, policy := range output.AssociatedAccessPolicies { + existingPolicies[*policy.PolicyArn] = policy + } + + if output.NextToken == nil { + break + } + + nextToken = output.NextToken + } + + return existingPolicies, nil +} + diff --git a/pkg/cloud/services/eks/accessentry_test.go b/pkg/cloud/services/eks/accessentry_test.go new file mode 100644 index 0000000000..2c89c7a7a3 --- /dev/null +++ b/pkg/cloud/services/eks/accessentry_test.go @@ -0,0 +1,799 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eks + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks" + ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/golang/mock/gomock" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + infrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + ekscontrolplanev1 "sigs.k8s.io/cluster-api-provider-aws/v2/controlplane/eks/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/scope" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services/eks/mock_eksiface" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +const ( + clusterName = "test-cluster" + principalARN = "arn:aws:iam::123456789012:role/my-role" + secondPrincipalARN = "arn:aws:iam::123456789012:role/second-role" + policyARN = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy" +) + +func TestReconcileAccessEntries(t *testing.T) { + tests := []struct { + name string + accessEntries []ekscontrolplanev1.AccessEntry + expect func(m *mock_eksiface.MockEKSAPIMockRecorder) + expectError bool + }{ + { + name: "no access entries", + accessEntries: []ekscontrolplanev1.AccessEntry{}, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) {}, + expectError: false, + }, + { + name: "create new access entry", + accessEntries: []ekscontrolplanev1.AccessEntry{ + { + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeStandard, + Username: "admin", + KubernetesGroups: []string{"system:masters"}, + AccessPolicies: []ekscontrolplanev1.AccessPolicyReference{ + { + PolicyARN: policyARN, + AccessScope: ekscontrolplanev1.AccessScope{ + Type: ekscontrolplanev1.AccessScopeTypeCluster, + }, + }, + }, + }, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.ListAccessEntries(gomock.Any(), gomock.Any()).Return(&eks.ListAccessEntriesOutput{ + AccessEntries: []string{}, + }, nil) + + m.CreateAccessEntry(gomock.Any(), &eks.CreateAccessEntryInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + Type: ekscontrolplanev1.AccessEntryTypeStandard.APIValue(), + Username: aws.String("admin"), + KubernetesGroups: []string{"system:masters"}, + }).Return(&eks.CreateAccessEntryOutput{}, nil) + + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{}, + }, nil) + + m.AssociateAccessPolicy(gomock.Any(), &eks.AssociateAccessPolicyInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + PolicyArn: aws.String(policyARN), + AccessScope: &ekstypes.AccessScope{ + Type: ekstypes.AccessScopeTypeCluster, + }, + }).Return(&eks.AssociateAccessPolicyOutput{}, nil) + }, + expectError: false, + }, + { + name: "update existing access entry", + accessEntries: []ekscontrolplanev1.AccessEntry{ + { + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeStandard, + Username: "admin-updated", + KubernetesGroups: []string{"system:masters", "developers"}, + AccessPolicies: []ekscontrolplanev1.AccessPolicyReference{ + { + PolicyARN: policyARN, + AccessScope: ekscontrolplanev1.AccessScope{ + Type: ekscontrolplanev1.AccessScopeTypeCluster, + }, + }, + }, + }, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.ListAccessEntries(gomock.Any(), gomock.Any()).Return(&eks.ListAccessEntriesOutput{ + AccessEntries: []string{principalARN}, + }, nil) + + m.DescribeAccessEntry(gomock.Any(), gomock.Any()).Return(&eks.DescribeAccessEntryOutput{ + AccessEntry: &ekstypes.AccessEntry{ + PrincipalArn: aws.String(principalARN), + Username: aws.String("admin"), + Type: ekscontrolplanev1.AccessEntryTypeStandard.APIValue(), + KubernetesGroups: []string{"system:masters"}, + }, + }, nil) + + m.UpdateAccessEntry(gomock.Any(), gomock.Any()).Return(&eks.UpdateAccessEntryOutput{}, nil) + + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{ + { + PolicyArn: aws.String(policyARN), + AccessScope: &ekstypes.AccessScope{ + Type: ekstypes.AccessScopeTypeCluster, + }, + }, + }, + }, nil) + + m.AssociateAccessPolicy(gomock.Any(), &eks.AssociateAccessPolicyInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + PolicyArn: aws.String(policyARN), + AccessScope: &ekstypes.AccessScope{ + Type: ekstypes.AccessScopeTypeCluster, + }, + }).Return(&eks.AssociateAccessPolicyOutput{}, nil) + }, + expectError: false, + }, + { + name: "delete access entry", + accessEntries: []ekscontrolplanev1.AccessEntry{ + { + PrincipalARN: principalARN, + Type: "STANDARD", + Username: "admin", + KubernetesGroups: []string{"system:masters"}, + AccessPolicies: []ekscontrolplanev1.AccessPolicyReference{ + { + PolicyARN: policyARN, + AccessScope: ekscontrolplanev1.AccessScope{ + Type: "cluster", + }, + }, + }, + }, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.ListAccessEntries(gomock.Any(), gomock.Any()).Return(&eks.ListAccessEntriesOutput{ + AccessEntries: []string{principalARN, secondPrincipalARN}, + }, nil) + + m.DescribeAccessEntry(gomock.Any(), gomock.Any()).Return(&eks.DescribeAccessEntryOutput{ + AccessEntry: &ekstypes.AccessEntry{ + PrincipalArn: aws.String(principalARN), + Username: aws.String("admin"), + Type: ekscontrolplanev1.AccessEntryTypeStandard.APIValue(), + KubernetesGroups: []string{"system:masters"}, + }, + }, nil) + + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{ + { + PolicyArn: aws.String(policyARN), + AccessScope: &ekstypes.AccessScope{ + Type: ekstypes.AccessScopeTypeCluster, + }, + }, + }, + }, nil) + + m.AssociateAccessPolicy(gomock.Any(), &eks.AssociateAccessPolicyInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + PolicyArn: aws.String(policyARN), + AccessScope: &ekstypes.AccessScope{ + Type: ekstypes.AccessScopeTypeCluster, + }, + }).Return(&eks.AssociateAccessPolicyOutput{}, nil) + + m.DeleteAccessEntry(gomock.Any(), &eks.DeleteAccessEntryInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(secondPrincipalARN), + }).Return(&eks.DeleteAccessEntryOutput{}, nil) + }, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + mockControl := gomock.NewController(t) + defer mockControl.Finish() + + eksMock := mock_eksiface.NewMockEKSAPI(mockControl) + + scheme := runtime.NewScheme() + _ = infrav1.AddToScheme(scheme) + _ = ekscontrolplanev1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + controlPlane := &ekscontrolplanev1.AWSManagedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: clusterName, + }, + Spec: ekscontrolplanev1.AWSManagedControlPlaneSpec{ + EKSClusterName: clusterName, + AccessConfig: &ekscontrolplanev1.AccessConfig{ + AuthenticationMode: ekscontrolplanev1.EKSAuthenticationModeAPIAndConfigMap, + }, + AccessEntries: tc.accessEntries, + }, + } + + scope, err := scope.NewManagedControlPlaneScope(scope.ManagedControlPlaneScopeParams{ + Client: client, + Cluster: &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: clusterName, + }, + }, + ControlPlane: controlPlane, + }) + g.Expect(err).To(BeNil()) + + tc.expect(eksMock.EXPECT()) + s := NewService(scope) + s.EKSClient = eksMock + + err = s.reconcileAccessEntries(context.TODO()) + if tc.expectError { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(BeNil()) + }) + } +} + +func TestReconcileAccessPolicies(t *testing.T) { + tests := []struct { + name string + accessEntry ekscontrolplanev1.AccessEntry + expect func(m *mock_eksiface.MockEKSAPIMockRecorder) + expectError bool + }{ + { + name: "ec2_linux Type skips policy reconciliation", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeEC2Linux, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) {}, + expectError: false, + }, + { + name: "ec2_windows Type skips policy reconciliation", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeEC2Windows, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) {}, + expectError: false, + }, + { + name: "associate new policy", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeStandard, + AccessPolicies: []ekscontrolplanev1.AccessPolicyReference{ + { + PolicyARN: policyARN, + AccessScope: ekscontrolplanev1.AccessScope{ + Type: ekscontrolplanev1.AccessScopeTypeCluster, + }, + }, + }, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{}, + }, nil) + + m.AssociateAccessPolicy(gomock.Any(), &eks.AssociateAccessPolicyInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + PolicyArn: aws.String(policyARN), + AccessScope: &ekstypes.AccessScope{ + Type: ekstypes.AccessScopeTypeCluster, + }, + }).Return(&eks.AssociateAccessPolicyOutput{}, nil) + }, + expectError: false, + }, + { + name: "disassociate policy", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeStandard, + AccessPolicies: []ekscontrolplanev1.AccessPolicyReference{}, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{ + { + PolicyArn: aws.String(policyARN), + AccessScope: &ekstypes.AccessScope{ + Type: ekstypes.AccessScopeTypeCluster, + }, + }, + }, + }, nil) + + m.DisassociateAccessPolicy(gomock.Any(), &eks.DisassociateAccessPolicyInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + PolicyArn: aws.String(policyARN), + }).Return(&eks.DisassociateAccessPolicyOutput{}, nil) + }, + expectError: false, + }, + { + name: "namespace scoped policy", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeStandard, + AccessPolicies: []ekscontrolplanev1.AccessPolicyReference{ + { + PolicyARN: policyARN, + AccessScope: ekscontrolplanev1.AccessScope{ + Type: ekscontrolplanev1.AccessScopeTypeNamespace, + Namespaces: []string{"kube-system", "default"}, + }, + }, + }, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{}, + }, nil) + + m.AssociateAccessPolicy(gomock.Any(), &eks.AssociateAccessPolicyInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + PolicyArn: aws.String(policyARN), + AccessScope: &ekstypes.AccessScope{ + Type: ekstypes.AccessScopeTypeNamespace, + Namespaces: []string{"kube-system", "default"}, + }, + }).Return(&eks.AssociateAccessPolicyOutput{}, nil) + }, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + mockControl := gomock.NewController(t) + defer mockControl.Finish() + + eksMock := mock_eksiface.NewMockEKSAPI(mockControl) + + scheme := runtime.NewScheme() + _ = infrav1.AddToScheme(scheme) + _ = ekscontrolplanev1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + scope, err := scope.NewManagedControlPlaneScope(scope.ManagedControlPlaneScopeParams{ + Client: client, + Cluster: &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: clusterName, + }, + }, + ControlPlane: &ekscontrolplanev1.AWSManagedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: clusterName, + }, + Spec: ekscontrolplanev1.AWSManagedControlPlaneSpec{ + EKSClusterName: clusterName, + }, + }, + }) + g.Expect(err).To(BeNil()) + + tc.expect(eksMock.EXPECT()) + s := NewService(scope) + s.EKSClient = eksMock + + err = s.reconcileAccessPolicies(context.TODO(), tc.accessEntry) + if tc.expectError { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(BeNil()) + }) + } +} + +func TestCreateAccessEntry(t *testing.T) { + tests := []struct { + name string + accessEntry ekscontrolplanev1.AccessEntry + expect func(m *mock_eksiface.MockEKSAPIMockRecorder) + expectError bool + }{ + { + name: "basic access entry", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeStandard, + Username: "admin", + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.CreateAccessEntry(gomock.Any(), &eks.CreateAccessEntryInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + Type: ekscontrolplanev1.AccessEntryTypeStandard.APIValue(), + Username: aws.String("admin"), + }).Return(&eks.CreateAccessEntryOutput{}, nil) + + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{}, + }, nil) + }, + expectError: false, + }, + { + name: "access entry with groups", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeStandard, + Username: "admin", + KubernetesGroups: []string{"system:masters", "developers"}, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.CreateAccessEntry(gomock.Any(), &eks.CreateAccessEntryInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + Type: ekscontrolplanev1.AccessEntryTypeStandard.APIValue(), + Username: aws.String("admin"), + KubernetesGroups: []string{"system:masters", "developers"}, + }).Return(&eks.CreateAccessEntryOutput{}, nil) + + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{}, + }, nil) + }, + expectError: false, + }, + { + name: "api error", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeStandard, + Username: "admin", + KubernetesGroups: []string{"system:masters"}, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.CreateAccessEntry(gomock.Any(), gomock.Any()).Return(nil, &ekstypes.InvalidParameterException{ + Message: aws.String("error creating access entry"), + }) + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + mockControl := gomock.NewController(t) + defer mockControl.Finish() + + eksMock := mock_eksiface.NewMockEKSAPI(mockControl) + + scheme := runtime.NewScheme() + _ = infrav1.AddToScheme(scheme) + _ = ekscontrolplanev1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + scope, err := scope.NewManagedControlPlaneScope(scope.ManagedControlPlaneScopeParams{ + Client: client, + Cluster: &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: clusterName, + }, + }, + ControlPlane: &ekscontrolplanev1.AWSManagedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: clusterName, + }, + Spec: ekscontrolplanev1.AWSManagedControlPlaneSpec{ + EKSClusterName: clusterName, + }, + }, + }) + g.Expect(err).To(BeNil()) + + tc.expect(eksMock.EXPECT()) + s := NewService(scope) + s.EKSClient = eksMock + + err = s.createAccessEntry(context.TODO(), tc.accessEntry) + if tc.expectError { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(BeNil()) + }) + } +} + +func TestUpdateAccessEntry(t *testing.T) { + tests := []struct { + name string + accessEntry ekscontrolplanev1.AccessEntry + expect func(m *mock_eksiface.MockEKSAPIMockRecorder) + expectError bool + }{ + { + name: "no updates needed", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeStandard, + Username: "admin", + KubernetesGroups: []string{"system:masters"}, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.DescribeAccessEntry(gomock.Any(), gomock.Any()).Return(&eks.DescribeAccessEntryOutput{ + AccessEntry: &ekstypes.AccessEntry{ + PrincipalArn: aws.String(principalARN), + Type: ekscontrolplanev1.AccessEntryTypeStandard.APIValue(), + Username: aws.String("admin"), + KubernetesGroups: []string{"system:masters"}, + }, + }, nil) + + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{}, + }, nil) + }, + expectError: false, + }, + { + name: "type change requires recreate", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeFargateLinux, + Username: "admin", + KubernetesGroups: []string{"system:masters"}, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.DescribeAccessEntry(gomock.Any(), gomock.Any()).Return(&eks.DescribeAccessEntryOutput{ + AccessEntry: &ekstypes.AccessEntry{ + PrincipalArn: aws.String(principalARN), + Type: ekscontrolplanev1.AccessEntryTypeStandard.APIValue(), + Username: aws.String("admin"), + KubernetesGroups: []string{"system:masters"}, + }, + }, nil) + + m.DeleteAccessEntry(gomock.Any(), gomock.Any()).Return(&eks.DeleteAccessEntryOutput{}, nil) + + m.CreateAccessEntry(gomock.Any(), gomock.Any()).Return(&eks.CreateAccessEntryOutput{}, nil) + + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{}, + }, nil) + }, + expectError: false, + }, + { + name: "username update", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeStandard, + Username: "new-admin", + KubernetesGroups: []string{"system:masters"}, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.DescribeAccessEntry(gomock.Any(), gomock.Any()).Return(&eks.DescribeAccessEntryOutput{ + AccessEntry: &ekstypes.AccessEntry{ + PrincipalArn: aws.String(principalARN), + Type: ekscontrolplanev1.AccessEntryTypeStandard.APIValue(), + Username: aws.String("admin"), + KubernetesGroups: []string{"system:masters"}, + }, + }, nil) + + m.UpdateAccessEntry(gomock.Any(), &eks.UpdateAccessEntryInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + Username: aws.String("new-admin"), + }).Return(&eks.UpdateAccessEntryOutput{}, nil) + + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{}, + }, nil) + }, + expectError: false, + }, + { + name: "kubernetes groups update", + accessEntry: ekscontrolplanev1.AccessEntry{ + PrincipalARN: principalARN, + Type: ekscontrolplanev1.AccessEntryTypeStandard, + Username: "admin", + KubernetesGroups: []string{"developers"}, + }, + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.DescribeAccessEntry(gomock.Any(), gomock.Any()).Return(&eks.DescribeAccessEntryOutput{ + AccessEntry: &ekstypes.AccessEntry{ + PrincipalArn: aws.String(principalARN), + Type: ekscontrolplanev1.AccessEntryTypeStandard.APIValue(), + Username: aws.String("admin"), + KubernetesGroups: []string{"system:masters"}, + }, + }, nil) + + m.UpdateAccessEntry(gomock.Any(), &eks.UpdateAccessEntryInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + KubernetesGroups: []string{"developers"}, + }).Return(&eks.UpdateAccessEntryOutput{}, nil) + + m.ListAssociatedAccessPolicies(gomock.Any(), gomock.Any()).Return(&eks.ListAssociatedAccessPoliciesOutput{ + AssociatedAccessPolicies: []ekstypes.AssociatedAccessPolicy{}, + }, nil) + }, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + mockControl := gomock.NewController(t) + defer mockControl.Finish() + + eksMock := mock_eksiface.NewMockEKSAPI(mockControl) + + scheme := runtime.NewScheme() + _ = infrav1.AddToScheme(scheme) + _ = ekscontrolplanev1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + scope, err := scope.NewManagedControlPlaneScope(scope.ManagedControlPlaneScopeParams{ + Client: client, + Cluster: &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: clusterName, + }, + }, + ControlPlane: &ekscontrolplanev1.AWSManagedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: clusterName, + }, + Spec: ekscontrolplanev1.AWSManagedControlPlaneSpec{ + EKSClusterName: clusterName, + }, + }, + }) + g.Expect(err).To(BeNil()) + + tc.expect(eksMock.EXPECT()) + s := NewService(scope) + s.EKSClient = eksMock + + err = s.updateAccessEntry(context.TODO(), tc.accessEntry) + if tc.expectError { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(BeNil()) + }) + } +} + +func TestDeleteAccessEntry(t *testing.T) { + tests := []struct { + name string + expect func(m *mock_eksiface.MockEKSAPIMockRecorder) + expectError bool + }{ + { + name: "successful delete", + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.DeleteAccessEntry(gomock.Any(), &eks.DeleteAccessEntryInput{ + ClusterName: aws.String(clusterName), + PrincipalArn: aws.String(principalARN), + }).Return(&eks.DeleteAccessEntryOutput{}, nil) + }, + expectError: false, + }, + { + name: "api error", + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m.DeleteAccessEntry(gomock.Any(), gomock.Any()).Return(nil, &ekstypes.ResourceNotFoundException{ + Message: aws.String("access entry not found"), + }) + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + mockControl := gomock.NewController(t) + defer mockControl.Finish() + + eksMock := mock_eksiface.NewMockEKSAPI(mockControl) + + scheme := runtime.NewScheme() + _ = infrav1.AddToScheme(scheme) + _ = ekscontrolplanev1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + scope, err := scope.NewManagedControlPlaneScope(scope.ManagedControlPlaneScopeParams{ + Client: client, + Cluster: &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: clusterName, + }, + }, + ControlPlane: &ekscontrolplanev1.AWSManagedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: clusterName, + }, + Spec: ekscontrolplanev1.AWSManagedControlPlaneSpec{ + EKSClusterName: clusterName, + }, + }, + }) + g.Expect(err).To(BeNil()) + + tc.expect(eksMock.EXPECT()) + s := NewService(scope) + s.EKSClient = eksMock + + err = s.deleteAccessEntry(context.TODO(), principalARN) + if tc.expectError { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(BeNil()) + }) + } +} + diff --git a/pkg/cloud/services/eks/cluster.go b/pkg/cloud/services/eks/cluster.go index b1b480e0b2..badd58fc02 100644 --- a/pkg/cloud/services/eks/cluster.go +++ b/pkg/cloud/services/eks/cluster.go @@ -125,6 +125,10 @@ func (s *Service) reconcileCluster(ctx context.Context) error { return errors.Wrap(err, "failed reconciling access config") } + if err := s.reconcileAccessEntries(ctx); err != nil { + return errors.Wrap(err, "failed reconciling access entries") + } + if err := s.reconcileLogging(ctx, cluster.Logging); err != nil { return errors.Wrap(err, "failed reconciling logging") } diff --git a/pkg/cloud/services/eks/mock_eksiface/eksapi_mock.go b/pkg/cloud/services/eks/mock_eksiface/eksapi_mock.go index cbc119e961..20e7898593 100644 --- a/pkg/cloud/services/eks/mock_eksiface/eksapi_mock.go +++ b/pkg/cloud/services/eks/mock_eksiface/eksapi_mock.go @@ -52,6 +52,26 @@ func (m *MockEKSAPI) EXPECT() *MockEKSAPIMockRecorder { return m.recorder } +// AssociateAccessPolicy mocks base method. +func (m *MockEKSAPI) AssociateAccessPolicy(arg0 context.Context, arg1 *eks.AssociateAccessPolicyInput, arg2 ...func(*eks.Options)) (*eks.AssociateAccessPolicyOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AssociateAccessPolicy", varargs...) + ret0, _ := ret[0].(*eks.AssociateAccessPolicyOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AssociateAccessPolicy indicates an expected call of AssociateAccessPolicy. +func (mr *MockEKSAPIMockRecorder) AssociateAccessPolicy(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AssociateAccessPolicy", reflect.TypeOf((*MockEKSAPI)(nil).AssociateAccessPolicy), varargs...) +} + // AssociateEncryptionConfig mocks base method. func (m *MockEKSAPI) AssociateEncryptionConfig(arg0 context.Context, arg1 *eks.AssociateEncryptionConfigInput, arg2 ...func(*eks.Options)) (*eks.AssociateEncryptionConfigOutput, error) { m.ctrl.T.Helper() @@ -92,6 +112,26 @@ func (mr *MockEKSAPIMockRecorder) AssociateIdentityProviderConfig(arg0, arg1 int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AssociateIdentityProviderConfig", reflect.TypeOf((*MockEKSAPI)(nil).AssociateIdentityProviderConfig), varargs...) } +// CreateAccessEntry mocks base method. +func (m *MockEKSAPI) CreateAccessEntry(arg0 context.Context, arg1 *eks.CreateAccessEntryInput, arg2 ...func(*eks.Options)) (*eks.CreateAccessEntryOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CreateAccessEntry", varargs...) + ret0, _ := ret[0].(*eks.CreateAccessEntryOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateAccessEntry indicates an expected call of CreateAccessEntry. +func (mr *MockEKSAPIMockRecorder) CreateAccessEntry(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccessEntry", reflect.TypeOf((*MockEKSAPI)(nil).CreateAccessEntry), varargs...) +} + // CreateAddon mocks base method. func (m *MockEKSAPI) CreateAddon(arg0 context.Context, arg1 *eks.CreateAddonInput, arg2 ...func(*eks.Options)) (*eks.CreateAddonOutput, error) { m.ctrl.T.Helper() @@ -172,6 +212,26 @@ func (mr *MockEKSAPIMockRecorder) CreateNodegroup(arg0, arg1 interface{}, arg2 . return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNodegroup", reflect.TypeOf((*MockEKSAPI)(nil).CreateNodegroup), varargs...) } +// DeleteAccessEntry mocks base method. +func (m *MockEKSAPI) DeleteAccessEntry(arg0 context.Context, arg1 *eks.DeleteAccessEntryInput, arg2 ...func(*eks.Options)) (*eks.DeleteAccessEntryOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DeleteAccessEntry", varargs...) + ret0, _ := ret[0].(*eks.DeleteAccessEntryOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteAccessEntry indicates an expected call of DeleteAccessEntry. +func (mr *MockEKSAPIMockRecorder) DeleteAccessEntry(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccessEntry", reflect.TypeOf((*MockEKSAPI)(nil).DeleteAccessEntry), varargs...) +} + // DeleteAddon mocks base method. func (m *MockEKSAPI) DeleteAddon(arg0 context.Context, arg1 *eks.DeleteAddonInput, arg2 ...func(*eks.Options)) (*eks.DeleteAddonOutput, error) { m.ctrl.T.Helper() @@ -252,6 +312,26 @@ func (mr *MockEKSAPIMockRecorder) DeleteNodegroup(arg0, arg1 interface{}, arg2 . return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNodegroup", reflect.TypeOf((*MockEKSAPI)(nil).DeleteNodegroup), varargs...) } +// DescribeAccessEntry mocks base method. +func (m *MockEKSAPI) DescribeAccessEntry(arg0 context.Context, arg1 *eks.DescribeAccessEntryInput, arg2 ...func(*eks.Options)) (*eks.DescribeAccessEntryOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DescribeAccessEntry", varargs...) + ret0, _ := ret[0].(*eks.DescribeAccessEntryOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DescribeAccessEntry indicates an expected call of DescribeAccessEntry. +func (mr *MockEKSAPIMockRecorder) DescribeAccessEntry(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeAccessEntry", reflect.TypeOf((*MockEKSAPI)(nil).DescribeAccessEntry), varargs...) +} + // DescribeAddon mocks base method. func (m *MockEKSAPI) DescribeAddon(arg0 context.Context, arg1 *eks.DescribeAddonInput, arg2 ...func(*eks.Options)) (*eks.DescribeAddonOutput, error) { m.ctrl.T.Helper() @@ -412,6 +492,26 @@ func (mr *MockEKSAPIMockRecorder) DescribeUpdate(arg0, arg1 interface{}, arg2 .. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeUpdate", reflect.TypeOf((*MockEKSAPI)(nil).DescribeUpdate), varargs...) } +// DisassociateAccessPolicy mocks base method. +func (m *MockEKSAPI) DisassociateAccessPolicy(arg0 context.Context, arg1 *eks.DisassociateAccessPolicyInput, arg2 ...func(*eks.Options)) (*eks.DisassociateAccessPolicyOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DisassociateAccessPolicy", varargs...) + ret0, _ := ret[0].(*eks.DisassociateAccessPolicyOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DisassociateAccessPolicy indicates an expected call of DisassociateAccessPolicy. +func (mr *MockEKSAPIMockRecorder) DisassociateAccessPolicy(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisassociateAccessPolicy", reflect.TypeOf((*MockEKSAPI)(nil).DisassociateAccessPolicy), varargs...) +} + // DisassociateIdentityProviderConfig mocks base method. func (m *MockEKSAPI) DisassociateIdentityProviderConfig(arg0 context.Context, arg1 *eks.DisassociateIdentityProviderConfigInput, arg2 ...func(*eks.Options)) (*eks.DisassociateIdentityProviderConfigOutput, error) { m.ctrl.T.Helper() @@ -432,6 +532,26 @@ func (mr *MockEKSAPIMockRecorder) DisassociateIdentityProviderConfig(arg0, arg1 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisassociateIdentityProviderConfig", reflect.TypeOf((*MockEKSAPI)(nil).DisassociateIdentityProviderConfig), varargs...) } +// ListAccessEntries mocks base method. +func (m *MockEKSAPI) ListAccessEntries(arg0 context.Context, arg1 *eks.ListAccessEntriesInput, arg2 ...func(*eks.Options)) (*eks.ListAccessEntriesOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListAccessEntries", varargs...) + ret0, _ := ret[0].(*eks.ListAccessEntriesOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAccessEntries indicates an expected call of ListAccessEntries. +func (mr *MockEKSAPIMockRecorder) ListAccessEntries(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAccessEntries", reflect.TypeOf((*MockEKSAPI)(nil).ListAccessEntries), varargs...) +} + // ListAddons mocks base method. func (m *MockEKSAPI) ListAddons(arg0 context.Context, arg1 *eks.ListAddonsInput, arg2 ...func(*eks.Options)) (*eks.ListAddonsOutput, error) { m.ctrl.T.Helper() @@ -452,6 +572,26 @@ func (mr *MockEKSAPIMockRecorder) ListAddons(arg0, arg1 interface{}, arg2 ...int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAddons", reflect.TypeOf((*MockEKSAPI)(nil).ListAddons), varargs...) } +// ListAssociatedAccessPolicies mocks base method. +func (m *MockEKSAPI) ListAssociatedAccessPolicies(arg0 context.Context, arg1 *eks.ListAssociatedAccessPoliciesInput, arg2 ...func(*eks.Options)) (*eks.ListAssociatedAccessPoliciesOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListAssociatedAccessPolicies", varargs...) + ret0, _ := ret[0].(*eks.ListAssociatedAccessPoliciesOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAssociatedAccessPolicies indicates an expected call of ListAssociatedAccessPolicies. +func (mr *MockEKSAPIMockRecorder) ListAssociatedAccessPolicies(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAssociatedAccessPolicies", reflect.TypeOf((*MockEKSAPI)(nil).ListAssociatedAccessPolicies), varargs...) +} + // ListClusters mocks base method. func (m *MockEKSAPI) ListClusters(arg0 context.Context, arg1 *eks.ListClustersInput, arg2 ...func(*eks.Options)) (*eks.ListClustersOutput, error) { m.ctrl.T.Helper() @@ -532,6 +672,26 @@ func (mr *MockEKSAPIMockRecorder) UntagResource(arg0, arg1 interface{}, arg2 ... return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UntagResource", reflect.TypeOf((*MockEKSAPI)(nil).UntagResource), varargs...) } +// UpdateAccessEntry mocks base method. +func (m *MockEKSAPI) UpdateAccessEntry(arg0 context.Context, arg1 *eks.UpdateAccessEntryInput, arg2 ...func(*eks.Options)) (*eks.UpdateAccessEntryOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "UpdateAccessEntry", varargs...) + ret0, _ := ret[0].(*eks.UpdateAccessEntryOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateAccessEntry indicates an expected call of UpdateAccessEntry. +func (mr *MockEKSAPIMockRecorder) UpdateAccessEntry(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccessEntry", reflect.TypeOf((*MockEKSAPI)(nil).UpdateAccessEntry), varargs...) +} + // UpdateAddon mocks base method. func (m *MockEKSAPI) UpdateAddon(arg0 context.Context, arg1 *eks.UpdateAddonInput, arg2 ...func(*eks.Options)) (*eks.UpdateAddonOutput, error) { m.ctrl.T.Helper() diff --git a/pkg/cloud/services/eks/service.go b/pkg/cloud/services/eks/service.go index 9d1ab00c7f..814c8d21b2 100644 --- a/pkg/cloud/services/eks/service.go +++ b/pkg/cloud/services/eks/service.go @@ -62,6 +62,14 @@ type EKSAPI interface { TagResource(ctx context.Context, params *eks.TagResourceInput, optFns ...func(*eks.Options)) (*eks.TagResourceOutput, error) UntagResource(ctx context.Context, params *eks.UntagResourceInput, optFns ...func(*eks.Options)) (*eks.UntagResourceOutput, error) DisassociateIdentityProviderConfig(ctx context.Context, params *eks.DisassociateIdentityProviderConfigInput, optFns ...func(*eks.Options)) (*eks.DisassociateIdentityProviderConfigOutput, error) + ListAccessEntries(ctx context.Context, params *eks.ListAccessEntriesInput, optFns ...func(*eks.Options)) (*eks.ListAccessEntriesOutput, error) + DescribeAccessEntry(ctx context.Context, params *eks.DescribeAccessEntryInput, optFns ...func(*eks.Options)) (*eks.DescribeAccessEntryOutput, error) + CreateAccessEntry(ctx context.Context, params *eks.CreateAccessEntryInput, optFns ...func(*eks.Options)) (*eks.CreateAccessEntryOutput, error) + UpdateAccessEntry(ctx context.Context, params *eks.UpdateAccessEntryInput, optFns ...func(*eks.Options)) (*eks.UpdateAccessEntryOutput, error) + DeleteAccessEntry(ctx context.Context, params *eks.DeleteAccessEntryInput, optFns ...func(*eks.Options)) (*eks.DeleteAccessEntryOutput, error) + ListAssociatedAccessPolicies(ctx context.Context, params *eks.ListAssociatedAccessPoliciesInput, optFns ...func(*eks.Options)) (*eks.ListAssociatedAccessPoliciesOutput, error) + AssociateAccessPolicy(ctx context.Context, params *eks.AssociateAccessPolicyInput, optFns ...func(*eks.Options)) (*eks.AssociateAccessPolicyOutput, error) + DisassociateAccessPolicy(ctx context.Context, params *eks.DisassociateAccessPolicyInput, optFns ...func(*eks.Options)) (*eks.DisassociateAccessPolicyOutput, error) // Waiters for EKS Cluster WaitUntilClusterActive(ctx context.Context, params *eks.DescribeClusterInput, maxWait time.Duration) error diff --git a/test/e2e/data/eks/cluster-template-eks-control-plane-only-with-accessentries.yaml b/test/e2e/data/eks/cluster-template-eks-control-plane-only-with-accessentries.yaml new file mode 100644 index 0000000000..06e6f15b5f --- /dev/null +++ b/test/e2e/data/eks/cluster-template-eks-control-plane-only-with-accessentries.yaml @@ -0,0 +1,51 @@ +--- +apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: "${CLUSTER_NAME}" +spec: + clusterNetwork: + pods: + cidrBlocks: ["192.168.0.0/16"] + infrastructureRef: + kind: AWSManagedControlPlane + apiVersion: controlplane.cluster.x-k8s.io/v1beta2 + name: "${CLUSTER_NAME}-control-plane" + controlPlaneRef: + kind: AWSManagedControlPlane + apiVersion: controlplane.cluster.x-k8s.io/v1beta2 + name: "${CLUSTER_NAME}-control-plane" +--- +kind: AWSManagedControlPlane +apiVersion: controlplane.cluster.x-k8s.io/v1beta2 +metadata: + name: "${CLUSTER_NAME}-control-plane" +spec: + region: "${AWS_REGION}" + sshKeyName: "${AWS_SSH_KEY_NAME}" + version: "${KUBERNETES_VERSION}" + accessConfig: + authenticationMode: API + accessEntries: + - principalARN: "arn:aws:iam::123456789012:role/KubernetesAdmin" + type: standard + username: kubernetes-admin + kubernetesGroups: + - system:masters + accessPolicies: + - policyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy" + accessScope: + type: cluster + - principalARN: "arn:aws:iam::123456789012:role/DeveloperRole" + type: standard + username: developer + kubernetesGroups: + - developers + accessPolicies: + - policyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy" + accessScope: + type: namespace + namespaces: ["default"] + identityRef: + kind: AWSClusterStaticIdentity + name: e2e-account diff --git a/test/e2e/suites/managed/eks_access_entries_test.go b/test/e2e/suites/managed/eks_access_entries_test.go new file mode 100644 index 0000000000..c3dc414684 --- /dev/null +++ b/test/e2e/suites/managed/eks_access_entries_test.go @@ -0,0 +1,147 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package managed + +import ( + "context" + "fmt" + + ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + ekscontrolplanev1 "sigs.k8s.io/cluster-api-provider-aws/v2/controlplane/eks/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/test/e2e/shared" + "sigs.k8s.io/cluster-api/test/framework" + "sigs.k8s.io/cluster-api/util" +) + +// EKS authentication mode e2e tests. +var _ = ginkgo.Describe("[managed] [auth] EKS authentication mode tests", func() { + var ( + namespace *corev1.Namespace + ctx context.Context + specName = "auth" + clusterName string + ) + + shared.ConditionalIt(runGeneralTests, "should create a cluster with access entries", func() { + ginkgo.By("should have a valid test configuration") + Expect(e2eCtx.Environment.BootstrapClusterProxy).ToNot(BeNil(), "Invalid argument. BootstrapClusterProxy can't be nil") + Expect(e2eCtx.E2EConfig).ToNot(BeNil(), "Invalid argument. e2eConfig can't be nil when calling %s spec", specName) + Expect(e2eCtx.E2EConfig.Variables).To(HaveKey(shared.KubernetesVersion)) + + ctx = context.TODO() + namespace = shared.SetupSpecNamespace(ctx, specName, e2eCtx) + clusterName = fmt.Sprintf("%s-%s", specName, util.RandomString(6)) + eksClusterName := getEKSClusterName(namespace.Name, clusterName) + + ginkgo.By("should create an EKS control plane with access entries enabled") + ManagedClusterSpec(ctx, func() ManagedClusterSpecInput { + return ManagedClusterSpecInput{ + E2EConfig: e2eCtx.E2EConfig, + ConfigClusterFn: defaultConfigCluster, + BootstrapClusterProxy: e2eCtx.Environment.BootstrapClusterProxy, + AWSSession: e2eCtx.BootstrapUserAWSSession, + Namespace: namespace, + ClusterName: clusterName, + Flavour: EKSAuthAPIAndConfigMapFlavor, + ControlPlaneMachineCount: 1, + WorkerMachineCount: 0, + } + }) + + ginkgo.By("EKS cluster should be active") + verifyClusterActiveAndOwned(ctx, eksClusterName, e2eCtx.BootstrapUserAWSSession) + + ginkgo.By("should create a cluster with access entries") + ManagedClusterSpec(ctx, func() ManagedClusterSpecInput { + return ManagedClusterSpecInput{ + E2EConfig: e2eCtx.E2EConfig, + ConfigClusterFn: defaultConfigCluster, + BootstrapClusterProxy: e2eCtx.Environment.BootstrapClusterProxy, + AWSSession: e2eCtx.BootstrapUserAWSSession, + Namespace: namespace, + ClusterName: clusterName, + Flavour: EKSControlPlaneOnlyWithAccessEntriesFlavor, + ControlPlaneMachineCount: 1, // NOTE: this cannot be zero as clusterctl returns an error + WorkerMachineCount: 0, + } + }) + + ginkgo.By("should have created the expected access entries") + expectedEntries := []ekscontrolplanev1.AccessEntry{ + { + PrincipalARN: "arn:aws:iam::123456789012:role/KubernetesAdmin", + Type: "STANDARD", + Username: "kubernetes-admin", + KubernetesGroups: []string{"system:masters"}, + AccessPolicies: []ekscontrolplanev1.AccessPolicyReference{ + { + PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy", + AccessScope: ekscontrolplanev1.AccessScope{ + Type: "cluster", + }, + }, + }, + }, + { + PrincipalARN: "arn:aws:iam::123456789012:role/DeveloperRole", + Type: "STANDARD", + Username: "developer", + KubernetesGroups: []string{"developers"}, + AccessPolicies: []ekscontrolplanev1.AccessPolicyReference{ + { + PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", + AccessScope: ekscontrolplanev1.AccessScope{ + Type: "namespace", + Namespaces: []string{"default"}, + }, + }, + }, + }, + } + verifyAccessEntries(ctx, eksClusterName, expectedEntries, e2eCtx.BootstrapUserAWSSession) + + + ginkgo.By("EKS cluster should be active") + verifyClusterActiveAndOwned(ctx, eksClusterName, e2eCtx.BootstrapUserAWSSession) + + cluster := framework.GetClusterByName(ctx, framework.GetClusterByNameInput{ + Getter: e2eCtx.Environment.BootstrapClusterProxy.GetClient(), + Namespace: namespace.Name, + Name: clusterName, + }) + Expect(cluster).NotTo(BeNil(), "couldn't find CAPI cluster") + + framework.DeleteCluster(ctx, framework.DeleteClusterInput{ + Deleter: e2eCtx.Environment.BootstrapClusterProxy.GetClient(), + Cluster: cluster, + }) + framework.WaitForClusterDeleted(ctx, framework.WaitForClusterDeletedInput{ + ClusterProxy: e2eCtx.Environment.BootstrapClusterProxy, + Cluster: cluster, + ClusterctlConfigPath: e2eCtx.Environment.ClusterctlConfigPath, + ArtifactFolder: e2eCtx.Settings.ArtifactFolder, + }, e2eCtx.E2EConfig.GetIntervals("", "wait-delete-cluster")...) + }) +}) diff --git a/test/e2e/suites/managed/helpers.go b/test/e2e/suites/managed/helpers.go index 926d914248..950685b3ae 100644 --- a/test/e2e/suites/managed/helpers.go +++ b/test/e2e/suites/managed/helpers.go @@ -22,6 +22,7 @@ package managed import ( "context" "fmt" + "slices" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -35,6 +36,7 @@ import ( crclient "sigs.k8s.io/controller-runtime/pkg/client" infrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + ekscontrolplanev1 "sigs.k8s.io/cluster-api-provider-aws/v2/controlplane/eks/api/v1beta2" "sigs.k8s.io/cluster-api/test/framework/clusterctl" ) @@ -52,6 +54,7 @@ const ( EKSClusterClassFlavor = "eks-clusterclass" EKSAuthAPIAndConfigMapFlavor = "eks-auth-api-and-config-map" EKSAuthBootstrapDisabledFlavor = "eks-auth-bootstrap-disabled" + EKSControlPlaneOnlyWithAccessEntriesFlavor = "eks-control-plane-only-with-accessentries" ) const ( @@ -249,3 +252,68 @@ func verifyASG(eksClusterName, asgName string, checkOwned bool, cfg *aws.Config) Expect(found).To(BeTrue(), "expecting the cluster owned tag to exist") } } + +func verifyAccessEntries(ctx context.Context, eksClusterName string, expectedEntries []ekscontrolplanev1.AccessEntry, cfg *aws.Config) { + eksClient := eks.NewFromConfig(*cfg) + + listOutput, err := eksClient.ListAccessEntries(ctx, &eks.ListAccessEntriesInput{ + ClusterName: &eksClusterName, + }) + Expect(err).ToNot(HaveOccurred(), "failed to list access entries") + + expectedEntriesMap := make(map[string]ekscontrolplanev1.AccessEntry, len(expectedEntries)) + for _, entry := range expectedEntries { + expectedEntriesMap[entry.PrincipalARN] = entry + } + + for _, principalARN := range listOutput.AccessEntries { + expectedEntry, exists := expectedEntriesMap[principalARN] + Expect(exists).To(BeTrue(), fmt.Sprintf("unexpected access entry: %s", principalARN)) + + describeOutput, err := eksClient.DescribeAccessEntry(ctx, &eks.DescribeAccessEntryInput{ + ClusterName: &eksClusterName, + PrincipalArn: &principalARN, + }) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("failed to describe access entry: %s", principalARN)) + + Expect(describeOutput.AccessEntry.Type).To(Equal(expectedEntry.Type), "access entry type does not match") + Expect(describeOutput.AccessEntry.Username).To(Equal(expectedEntry.Username), "access entry username does not match") + + if len(expectedEntry.KubernetesGroups) > 0 { + slices.Sort(expectedEntry.KubernetesGroups) + slices.Sort(describeOutput.AccessEntry.KubernetesGroups) + Expect(describeOutput.AccessEntry.KubernetesGroups).To(Equal(expectedEntry.KubernetesGroups), "access entry kubernetes groups do not match") + } + + if len(expectedEntry.AccessPolicies) > 0 { + listOutput, err := eksClient.ListAssociatedAccessPolicies(ctx, &eks.ListAssociatedAccessPoliciesInput{ + ClusterName: &eksClusterName, + PrincipalArn: &principalARN, + }) + Expect(err).ToNot(HaveOccurred(), "failed to list access policies") + + expectedPolicies := make(map[string]ekscontrolplanev1.AccessPolicyReference, len(expectedEntry.AccessPolicies)) + for _, policy := range expectedEntry.AccessPolicies { + expectedPolicies[policy.PolicyARN] = policy + } + + for _, policy := range listOutput.AssociatedAccessPolicies { + expectedPolicy, exists := expectedPolicies[*policy.PolicyArn] + Expect(exists).To(BeTrue(), fmt.Sprintf("unexpected access policy: %s", *policy.PolicyArn)) + + Expect(policy.AccessScope.Type).To(Equal(expectedPolicy.AccessScope.Type), "access policy scope type does not match") + + if expectedPolicy.AccessScope.Type == "namespace" { + slices.Sort(expectedPolicy.AccessScope.Namespaces) + slices.Sort(policy.AccessScope.Namespaces) + Expect(policy.AccessScope.Namespaces).To(Equal(expectedPolicy.AccessScope.Namespaces), "access policy scope namespaces do not match") + } + + delete(expectedPolicies, *policy.PolicyArn) + } + Expect(expectedPolicies).To(BeEmpty(), "not all expected access policies were found") + } + delete(expectedEntriesMap, principalARN) + } + Expect(expectedEntriesMap).To(BeEmpty(), "not all expected access entries were found") +}