diff --git a/apis/v1alpha1/ack-generate-metadata.yaml b/apis/v1alpha1/ack-generate-metadata.yaml index f15fb18..c9e4651 100755 --- a/apis/v1alpha1/ack-generate-metadata.yaml +++ b/apis/v1alpha1/ack-generate-metadata.yaml @@ -1,13 +1,13 @@ ack_generate_info: - build_date: "2025-11-29T03:25:13Z" - build_hash: 23c7074fa310ad1ccb38946775397c203b49f024 - go_version: go1.25.4 - version: v0.56.0 -api_directory_checksum: fcb205ac280ed1b0f107a291e5ea43d93c0991e9 + build_date: "2025-12-11T00:17:14Z" + build_hash: 5c8b9050006ef6c7d3a97c279e7b1bc163f20a0a + go_version: go1.24.0 + version: v0.56.0-3-g5c8b905 +api_directory_checksum: 1395aec536d8707909426eb19b38cb474d815578 api_version: v1alpha1 aws_sdk_go_version: v1.32.6 generator_config_info: - file_checksum: ceef3af34f41f300f4d827886f35d272f50cb38c + file_checksum: f92b9883a39e21b7e7b2e2e6bfa5d180542e8303 original_file_name: generator.yaml last_modification: reason: API generation diff --git a/apis/v1alpha1/generator.yaml b/apis/v1alpha1/generator.yaml index 09ffcc9..f968d63 100644 --- a/apis/v1alpha1/generator.yaml +++ b/apis/v1alpha1/generator.yaml @@ -64,6 +64,14 @@ resources: references: resource: Policy path: Status.ACKResourceMetadata.ARN + # In order to support adding zero or more users to a group, we use + # custom update code that calls the AddUserToGroup and RemoveUserFromGroup + # APIs to manage the set of users in this Group. + Users: + type: "[]*string" + references: + resource: User + path: Spec.Name # These are policy documents that are added to the Group using the # Put/DeleteGroupPolicy APIs, as compared to the Attach/DetachGroupPolicy # APIs that are for non-inline managed policies. @@ -136,9 +144,9 @@ resources: sdk_delete_pre_build_request: template_path: hooks/policy/sdk_delete_pre_build_request.go.tpl update_operation: - # There is no `UpdatePolicy` API operation. The only way to update a + # There is no `UpdatePolicy` API operation. The only way to update a # policy is to update the properties individually (only a few properties - # support this) or to delete the policy and recreate it entirely. + # support this) or to delete the policy and recreate it entirely. # # This custom method will support updating the properties individually, # but there is currently no support for the delete/create option. diff --git a/apis/v1alpha1/group.go b/apis/v1alpha1/group.go index 0cf294e..8ec6a8d 100644 --- a/apis/v1alpha1/group.go +++ b/apis/v1alpha1/group.go @@ -60,6 +60,8 @@ type GroupSpec struct { Path *string `json:"path,omitempty"` Policies []*string `json:"policies,omitempty"` PolicyRefs []*ackv1alpha1.AWSResourceReferenceWrapper `json:"policyRefs,omitempty"` + UserRefs []*ackv1alpha1.AWSResourceReferenceWrapper `json:"userRefs,omitempty"` + Users []*string `json:"users,omitempty"` } // GroupStatus defines the observed state of Group diff --git a/apis/v1alpha1/zz_generated.deepcopy.go b/apis/v1alpha1/zz_generated.deepcopy.go index 1c68e4b..ed65bbb 100644 --- a/apis/v1alpha1/zz_generated.deepcopy.go +++ b/apis/v1alpha1/zz_generated.deepcopy.go @@ -403,6 +403,28 @@ func (in *GroupSpec) DeepCopyInto(out *GroupSpec) { } } } + if in.UserRefs != nil { + in, out := &in.UserRefs, &out.UserRefs + *out = make([]*corev1alpha1.AWSResourceReferenceWrapper, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(corev1alpha1.AWSResourceReferenceWrapper) + (*in).DeepCopyInto(*out) + } + } + } + if in.Users != nil { + in, out := &in.Users, &out.Users + *out = make([]*string, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(string) + **out = **in + } + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GroupSpec. diff --git a/config/controller/kustomization.yaml b/config/controller/kustomization.yaml index 45330a6..0143bbb 100644 --- a/config/controller/kustomization.yaml +++ b/config/controller/kustomization.yaml @@ -6,4 +6,4 @@ kind: Kustomization images: - name: controller newName: public.ecr.aws/aws-controllers-k8s/iam-controller - newTag: 1.6.0 + newTag: 0.0.0-non-release-version diff --git a/config/crd/bases/iam.services.k8s.aws_groups.yaml b/config/crd/bases/iam.services.k8s.aws_groups.yaml index 36dc7dd..03723d8 100644 --- a/config/crd/bases/iam.services.k8s.aws_groups.yaml +++ b/config/crd/bases/iam.services.k8s.aws_groups.yaml @@ -105,6 +105,29 @@ spec: type: object type: object type: array + userRefs: + items: + description: "AWSResourceReferenceWrapper provides a wrapper around + *AWSResourceReference\ntype to provide more user friendly syntax + for references using 'from' field\nEx:\nAPIIDRef:\n\n\tfrom:\n\t + \ name: my-api" + properties: + from: + description: |- + AWSResourceReference provides all the values necessary to reference another + k8s resource for finding the identifier(Id/ARN/Name) + properties: + name: + type: string + namespace: + type: string + type: object + type: object + type: array + users: + items: + type: string + type: array required: - name type: object diff --git a/config/crd/common/bases/services.k8s.aws_iamroleselectors.yaml b/config/crd/common/bases/services.k8s.aws_iamroleselectors.yaml index 9477c90..803a75c 100644 --- a/config/crd/common/bases/services.k8s.aws_iamroleselectors.yaml +++ b/config/crd/common/bases/services.k8s.aws_iamroleselectors.yaml @@ -63,6 +63,16 @@ spec: required: - names type: object + resourceLabelSelector: + description: LabelSelector is a label query over a set of resources. + properties: + matchLabels: + additionalProperties: + type: string + type: object + required: + - matchLabels + type: object resourceTypeSelector: items: properties: diff --git a/config/crd/common/kustomization.yaml b/config/crd/common/kustomization.yaml index 8165534..65cb01b 100644 --- a/config/crd/common/kustomization.yaml +++ b/config/crd/common/kustomization.yaml @@ -3,5 +3,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - bases/services.k8s.aws_iamroleselectors.yaml - bases/services.k8s.aws_fieldexports.yaml + - bases/services.k8s.aws_iamroleselectors.yaml diff --git a/generator.yaml b/generator.yaml index 09ffcc9..f968d63 100644 --- a/generator.yaml +++ b/generator.yaml @@ -64,6 +64,14 @@ resources: references: resource: Policy path: Status.ACKResourceMetadata.ARN + # In order to support adding zero or more users to a group, we use + # custom update code that calls the AddUserToGroup and RemoveUserFromGroup + # APIs to manage the set of users in this Group. + Users: + type: "[]*string" + references: + resource: User + path: Spec.Name # These are policy documents that are added to the Group using the # Put/DeleteGroupPolicy APIs, as compared to the Attach/DetachGroupPolicy # APIs that are for non-inline managed policies. @@ -136,9 +144,9 @@ resources: sdk_delete_pre_build_request: template_path: hooks/policy/sdk_delete_pre_build_request.go.tpl update_operation: - # There is no `UpdatePolicy` API operation. The only way to update a + # There is no `UpdatePolicy` API operation. The only way to update a # policy is to update the properties individually (only a few properties - # support this) or to delete the policy and recreate it entirely. + # support this) or to delete the policy and recreate it entirely. # # This custom method will support updating the properties individually, # but there is currently no support for the delete/create option. diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 5b80805..ddba43e 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v1 name: iam-chart description: A Helm chart for the ACK service controller for AWS Identity & Access Management (IAM) -version: 1.6.0 -appVersion: 1.6.0 +version: 0.0.0-non-release-version +appVersion: 0.0.0-non-release-version home: https://github.com/aws-controllers-k8s/iam-controller icon: https://raw.githubusercontent.com/aws/eks-charts/master/docs/logo/aws.png sources: diff --git a/helm/crds/iam.services.k8s.aws_groups.yaml b/helm/crds/iam.services.k8s.aws_groups.yaml index de2ba68..89f4fd2 100644 --- a/helm/crds/iam.services.k8s.aws_groups.yaml +++ b/helm/crds/iam.services.k8s.aws_groups.yaml @@ -105,6 +105,29 @@ spec: type: object type: object type: array + userRefs: + items: + description: "AWSResourceReferenceWrapper provides a wrapper around + *AWSResourceReference\ntype to provide more user friendly syntax + for references using 'from' field\nEx:\nAPIIDRef:\n\n\tfrom:\n\t + \ name: my-api" + properties: + from: + description: |- + AWSResourceReference provides all the values necessary to reference another + k8s resource for finding the identifier(Id/ARN/Name) + properties: + name: + type: string + namespace: + type: string + type: object + type: object + type: array + users: + items: + type: string + type: array required: - name type: object diff --git a/helm/crds/services.k8s.aws_iamroleselectors.yaml b/helm/crds/services.k8s.aws_iamroleselectors.yaml index 9477c90..803a75c 100644 --- a/helm/crds/services.k8s.aws_iamroleselectors.yaml +++ b/helm/crds/services.k8s.aws_iamroleselectors.yaml @@ -63,6 +63,16 @@ spec: required: - names type: object + resourceLabelSelector: + description: LabelSelector is a label query over a set of resources. + properties: + matchLabels: + additionalProperties: + type: string + type: object + required: + - matchLabels + type: object resourceTypeSelector: items: properties: diff --git a/helm/templates/NOTES.txt b/helm/templates/NOTES.txt index f6f4bc7..0a9df91 100644 --- a/helm/templates/NOTES.txt +++ b/helm/templates/NOTES.txt @@ -1,5 +1,5 @@ {{ .Chart.Name }} has been installed. -This chart deploys "public.ecr.aws/aws-controllers-k8s/iam-controller:1.6.0". +This chart deploys "public.ecr.aws/aws-controllers-k8s/iam-controller:0.0.0-non-release-version". Check its status by running: kubectl --namespace {{ .Release.Namespace }} get pods -l "app.kubernetes.io/instance={{ .Release.Name }}" diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 906c261..75c98ec 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -51,6 +51,13 @@ spec: - "$(AWS_REGION)" - --aws-endpoint-url - "$(AWS_ENDPOINT_URL)" +{{- if .Values.aws.identity_endpoint_url }} + - --aws-identity-endpoint-url + - "$(AWS_IDENTITY_ENDPOINT_URL)" +{{- end }} +{{- if .Values.aws.allow_unsafe_aws_endpoint_urls }} + - --allow-unsafe-aws-endpoint-urls +{{- end }} {{- if .Values.log.enable_development_logging }} - --enable-development-logging {{- end }} @@ -109,6 +116,8 @@ spec: value: {{ .Values.aws.region }} - name: AWS_ENDPOINT_URL value: {{ .Values.aws.endpoint_url | quote }} + - name: AWS_IDENTITY_ENDPOINT_URL + value: {{ .Values.aws.identity_endpoint_url | quote }} - name: ACK_WATCH_NAMESPACE value: {{ include "ack-iam-controller.watch-namespace" . }} - name: ACK_WATCH_SELECTORS diff --git a/helm/values.schema.json b/helm/values.schema.json index c3f56a0..619cfe3 100644 --- a/helm/values.schema.json +++ b/helm/values.schema.json @@ -171,9 +171,16 @@ "region": { "type": "string" }, - "endpoint": { + "endpoint_url": { "type": "string" }, + "identity_endpoint_url": { + "type": "string" + }, + "allow_unsafe_aws_endpoint_urls": { + "type": "boolean", + "default": false + }, "credentials": { "description": "AWS credentials information", "properties": { diff --git a/helm/values.yaml b/helm/values.yaml index daebb17..eb43131 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -4,7 +4,7 @@ image: repository: public.ecr.aws/aws-controllers-k8s/iam-controller - tag: 1.6.0 + tag: 0.0.0-non-release-version pullPolicy: IfNotPresent pullSecrets: [] @@ -90,6 +90,8 @@ aws: # If specified, use the AWS region for AWS API calls region: "" endpoint_url: "" + identity_endpoint_url: "" + allow_unsafe_aws_endpoint_urls: false credentials: # If specified, Secret with shared credentials file to use. secretName: "" diff --git a/pkg/resource/group/delta.go b/pkg/resource/group/delta.go index 72923b2..f489b49 100644 --- a/pkg/resource/group/delta.go +++ b/pkg/resource/group/delta.go @@ -74,6 +74,16 @@ func newResourceDelta( if !reflect.DeepEqual(a.ko.Spec.PolicyRefs, b.ko.Spec.PolicyRefs) { delta.Add("Spec.PolicyRefs", a.ko.Spec.PolicyRefs, b.ko.Spec.PolicyRefs) } + if !reflect.DeepEqual(a.ko.Spec.UserRefs, b.ko.Spec.UserRefs) { + delta.Add("Spec.UserRefs", a.ko.Spec.UserRefs, b.ko.Spec.UserRefs) + } + if len(a.ko.Spec.Users) != len(b.ko.Spec.Users) { + delta.Add("Spec.Users", a.ko.Spec.Users, b.ko.Spec.Users) + } else if len(a.ko.Spec.Users) > 0 { + if !ackcompare.SliceStringPEqual(a.ko.Spec.Users, b.ko.Spec.Users) { + delta.Add("Spec.Users", a.ko.Spec.Users, b.ko.Spec.Users) + } + } return delta } diff --git a/pkg/resource/group/hooks.go b/pkg/resource/group/hooks.go index 00e4880..f5dcbac 100644 --- a/pkg/resource/group/hooks.go +++ b/pkg/resource/group/hooks.go @@ -298,3 +298,121 @@ func (rm *resourceManager) removeInlinePolicy( func decodeDocument(encoded string) (string, error) { return url.QueryUnescape(encoded) } + +// getUsers returns the list of user names currently in the Group. +// The GetGroup API can return paginated results when a group has many users, +// so this function handles pagination using IsTruncated/Marker. +func (rm *resourceManager) getUsers( + ctx context.Context, + r *resource, +) ([]*string, error) { + var err error + rlog := ackrtlog.FromContext(ctx) + exit := rlog.Trace("rm.getUsers") + defer func() { exit(err) }() + + input := &svcsdk.GetGroupInput{} + input.GroupName = r.ko.Spec.Name + res := []*string{} + + var marker *string + for { + if marker != nil { + input.Marker = marker + } + resp, err := rm.sdkapi.GetGroup(ctx, input) + rm.metrics.RecordAPICall("READ_ONE", "GetGroup", err) + if err != nil { + return nil, err + } + for _, u := range resp.Users { + res = append(res, u.UserName) + } + if !resp.IsTruncated { + break + } + marker = resp.Marker + } + return res, nil +} + +// addUser adds the supplied user to the supplied Group resource +func (rm *resourceManager) addUser( + ctx context.Context, + r *resource, + userName *string, +) (err error) { + rlog := ackrtlog.FromContext(ctx) + exit := rlog.Trace("rm.addUser") + defer func() { exit(err) }() + + input := &svcsdk.AddUserToGroupInput{} + input.GroupName = r.ko.Spec.Name + input.UserName = userName + _, err = rm.sdkapi.AddUserToGroup(ctx, input) + rm.metrics.RecordAPICall("UPDATE", "AddUserToGroup", err) + return err +} + +// removeUser removes the supplied user from the supplied Group resource +func (rm *resourceManager) removeUser( + ctx context.Context, + r *resource, + userName *string, +) (err error) { + rlog := ackrtlog.FromContext(ctx) + exit := rlog.Trace("rm.removeUser") + defer func() { exit(err) }() + + input := &svcsdk.RemoveUserFromGroupInput{} + input.GroupName = r.ko.Spec.Name + input.UserName = userName + _, err = rm.sdkapi.RemoveUserFromGroup(ctx, input) + rm.metrics.RecordAPICall("UPDATE", "RemoveUserFromGroup", err) + return err +} + +// syncUsers examines the Users in the supplied Group and calls the +// AddUserToGroup and RemoveUserFromGroup APIs to ensure that the set of +// users stays in sync with the Group.Spec.Users field, which is a list +// of strings containing user names. +func (rm *resourceManager) syncUsers( + ctx context.Context, + desired *resource, + latest *resource, +) (err error) { + rlog := ackrtlog.FromContext(ctx) + exit := rlog.Trace("rm.syncUsers") + defer func() { exit(err) }() + toAdd := []*string{} + toDelete := []*string{} + + existingUsers := latest.ko.Spec.Users + + for _, u := range desired.ko.Spec.Users { + if !ackutil.InStringPs(*u, existingUsers) { + toAdd = append(toAdd, u) + } + } + + for _, u := range existingUsers { + if !ackutil.InStringPs(*u, desired.ko.Spec.Users) { + toDelete = append(toDelete, u) + } + } + + for _, u := range toAdd { + rlog.Debug("adding user to group", "user_name", *u) + if err = rm.addUser(ctx, desired, u); err != nil { + return err + } + } + for _, u := range toDelete { + rlog.Debug("removing user from group", "user_name", *u) + if err = rm.removeUser(ctx, desired, u); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/resource/group/references.go b/pkg/resource/group/references.go index 595dea8..eb2b2c5 100644 --- a/pkg/resource/group/references.go +++ b/pkg/resource/group/references.go @@ -41,6 +41,10 @@ func (rm *resourceManager) ClearResolvedReferences(res acktypes.AWSResource) ack ko.Spec.Policies = nil } + if len(ko.Spec.UserRefs) > 0 { + ko.Spec.Users = nil + } + return &resource{ko} } @@ -66,6 +70,12 @@ func (rm *resourceManager) ResolveReferences( resourceHasReferences = resourceHasReferences || fieldHasReferences } + if fieldHasReferences, err := rm.resolveReferenceForUsers(ctx, apiReader, ko); err != nil { + return &resource{ko}, (resourceHasReferences || fieldHasReferences), err + } else { + resourceHasReferences = resourceHasReferences || fieldHasReferences + } + return &resource{ko}, resourceHasReferences, err } @@ -76,6 +86,10 @@ func validateReferenceFields(ko *svcapitypes.Group) error { if len(ko.Spec.PolicyRefs) > 0 && len(ko.Spec.Policies) > 0 { return ackerr.ResourceReferenceAndIDNotSupportedFor("Policies", "PolicyRefs") } + + if len(ko.Spec.UserRefs) > 0 && len(ko.Spec.Users) > 0 { + return ackerr.ResourceReferenceAndIDNotSupportedFor("Users", "UserRefs") + } return nil } @@ -166,3 +180,91 @@ func getReferencedResourceState_Policy( } return nil } + +// resolveReferenceForUsers reads the resource referenced +// from UserRefs field and sets the Users +// from referenced resource. Returns a boolean indicating whether a reference +// contains references, or an error +func (rm *resourceManager) resolveReferenceForUsers( + ctx context.Context, + apiReader client.Reader, + ko *svcapitypes.Group, +) (hasReferences bool, err error) { + for _, f0iter := range ko.Spec.UserRefs { + if f0iter != nil && f0iter.From != nil { + hasReferences = true + arr := f0iter.From + if arr.Name == nil || *arr.Name == "" { + return hasReferences, fmt.Errorf("provided resource reference is nil or empty: UserRefs") + } + namespace := ko.ObjectMeta.GetNamespace() + if arr.Namespace != nil && *arr.Namespace != "" { + namespace = *arr.Namespace + } + obj := &svcapitypes.User{} + if err := getReferencedResourceState_User(ctx, apiReader, obj, *arr.Name, namespace); err != nil { + return hasReferences, err + } + if ko.Spec.Users == nil { + ko.Spec.Users = make([]*string, 0, 1) + } + ko.Spec.Users = append(ko.Spec.Users, (*string)(obj.Spec.Name)) + } + } + + return hasReferences, nil +} + +// getReferencedResourceState_User looks up whether a referenced resource +// exists and is in a ACK.ResourceSynced=True state. If the referenced resource does exist and is +// in a Synced state, returns nil, otherwise returns `ackerr.ResourceReferenceTerminalFor` or +// `ResourceReferenceNotSyncedFor` depending on if the resource is in a Terminal state. +func getReferencedResourceState_User( + ctx context.Context, + apiReader client.Reader, + obj *svcapitypes.User, + name string, // the Kubernetes name of the referenced resource + namespace string, // the Kubernetes namespace of the referenced resource +) error { + namespacedName := types.NamespacedName{ + Namespace: namespace, + Name: name, + } + err := apiReader.Get(ctx, namespacedName, obj) + if err != nil { + return err + } + var refResourceTerminal bool + for _, cond := range obj.Status.Conditions { + if cond.Type == ackv1alpha1.ConditionTypeTerminal && + cond.Status == corev1.ConditionTrue { + return ackerr.ResourceReferenceTerminalFor( + "User", + namespace, name) + } + } + if refResourceTerminal { + return ackerr.ResourceReferenceTerminalFor( + "User", + namespace, name) + } + var refResourceSynced bool + for _, cond := range obj.Status.Conditions { + if cond.Type == ackv1alpha1.ConditionTypeResourceSynced && + cond.Status == corev1.ConditionTrue { + refResourceSynced = true + } + } + if !refResourceSynced { + return ackerr.ResourceReferenceNotSyncedFor( + "User", + namespace, name) + } + if obj.Spec.Name == nil { + return ackerr.ResourceReferenceMissingTargetFieldFor( + "User", + namespace, name, + "Spec.Name") + } + return nil +} diff --git a/pkg/resource/group/sdk.go b/pkg/resource/group/sdk.go index b629ae0..540342f 100644 --- a/pkg/resource/group/sdk.go +++ b/pkg/resource/group/sdk.go @@ -126,6 +126,10 @@ func (rm *resourceManager) sdkFind( if err != nil { return nil, err } + ko.Spec.Users, err = rm.getUsers(ctx, &resource{ko}) + if err != nil { + return nil, err + } return &resource{ko}, nil } @@ -261,7 +265,13 @@ func (rm *resourceManager) sdkUpdate( return nil, err } } - if !delta.DifferentExcept("Spec.Tags", "Spec.Policies", "Spec.InlinePolicies", "Spec.PermissionsBoundary") { + if delta.DifferentAt("Spec.Users") { + err = rm.syncUsers(ctx, desired, latest) + if err != nil { + return nil, err + } + } + if !delta.DifferentExcept("Spec.Tags", "Spec.Policies", "Spec.InlinePolicies", "Spec.Users", "Spec.PermissionsBoundary") { return desired, nil } @@ -322,7 +332,7 @@ func (rm *resourceManager) sdkDelete( defer func() { exit(err) }() - // This deletes all associated managed and inline policies from the user + // This deletes all associated managed and inline policies and removes all users from the group groupCpy := r.ko.DeepCopy() groupCpy.Spec.Policies = nil if err := rm.syncManagedPolicies(ctx, &resource{ko: groupCpy}, r); err != nil { @@ -332,6 +342,10 @@ func (rm *resourceManager) sdkDelete( if err := rm.syncInlinePolicies(ctx, &resource{ko: groupCpy}, r); err != nil { return nil, err } + groupCpy.Spec.Users = nil + if err := rm.syncUsers(ctx, &resource{ko: groupCpy}, r); err != nil { + return nil, err + } input, err := rm.newDeleteRequestPayload(r) if err != nil { diff --git a/templates/hooks/group/sdk_delete_pre_build_request.go.tpl b/templates/hooks/group/sdk_delete_pre_build_request.go.tpl index 5efbca1..74808e3 100644 --- a/templates/hooks/group/sdk_delete_pre_build_request.go.tpl +++ b/templates/hooks/group/sdk_delete_pre_build_request.go.tpl @@ -1,4 +1,4 @@ - // This deletes all associated managed and inline policies from the user + // This deletes all associated managed and inline policies and removes all users from the group groupCpy := r.ko.DeepCopy() groupCpy.Spec.Policies = nil if err := rm.syncManagedPolicies(ctx, &resource{ko: groupCpy}, r); err != nil { @@ -8,3 +8,7 @@ if err := rm.syncInlinePolicies(ctx, &resource{ko: groupCpy}, r); err != nil { return nil, err } + groupCpy.Spec.Users = nil + if err := rm.syncUsers(ctx, &resource{ko: groupCpy}, r); err != nil { + return nil, err + } diff --git a/templates/hooks/group/sdk_read_one_post_set_output.go.tpl b/templates/hooks/group/sdk_read_one_post_set_output.go.tpl index 684f957..18ab7b6 100644 --- a/templates/hooks/group/sdk_read_one_post_set_output.go.tpl +++ b/templates/hooks/group/sdk_read_one_post_set_output.go.tpl @@ -6,3 +6,7 @@ if err != nil { return nil, err } + ko.Spec.Users, err = rm.getUsers(ctx, &resource{ko}) + if err != nil { + return nil, err + } diff --git a/templates/hooks/group/sdk_update_pre_build_request.go.tpl b/templates/hooks/group/sdk_update_pre_build_request.go.tpl index ca2148f..6099823 100644 --- a/templates/hooks/group/sdk_update_pre_build_request.go.tpl +++ b/templates/hooks/group/sdk_update_pre_build_request.go.tpl @@ -10,6 +10,12 @@ return nil, err } } - if !delta.DifferentExcept("Spec.Tags", "Spec.Policies", "Spec.InlinePolicies", "Spec.PermissionsBoundary") { + if delta.DifferentAt("Spec.Users") { + err = rm.syncUsers(ctx, desired, latest) + if err != nil { + return nil, err + } + } + if !delta.DifferentExcept("Spec.Tags", "Spec.Policies", "Spec.InlinePolicies", "Spec.Users", "Spec.PermissionsBoundary") { return desired, nil } diff --git a/test/e2e/group.py b/test/e2e/group.py index ee43dca..1ce60d4 100644 --- a/test/e2e/group.py +++ b/test/e2e/group.py @@ -135,3 +135,21 @@ def get_inline_policies(group_name): return policies except c.exceptions.NoSuchEntityException: return None + + +def get_users(group_name): + """Returns a list containing the user names that are members of the + supplied Group. + + If no such Group exists, returns None. + """ + c = boto3.client('iam') + try: + users = [] + paginator = c.get_paginator('get_group') + for page in paginator.paginate(GroupName=group_name): + for user in page['Users']: + users.append(user['UserName']) + return users + except c.exceptions.NoSuchEntityException: + return None diff --git a/test/e2e/resources/group_with_users.yaml b/test/e2e/resources/group_with_users.yaml new file mode 100644 index 0000000..fc56971 --- /dev/null +++ b/test/e2e/resources/group_with_users.yaml @@ -0,0 +1,8 @@ +apiVersion: iam.services.k8s.aws/v1alpha1 +kind: Group +metadata: + name: $GROUP_NAME +spec: + name: $GROUP_NAME + users: + - $USER_NAME diff --git a/test/e2e/tests/test_group.py b/test/e2e/tests/test_group.py index 0726b1c..cbfe556 100644 --- a/test/e2e/tests/test_group.py +++ b/test/e2e/tests/test_group.py @@ -202,3 +202,185 @@ def test_crud(self, simple_group): latest_inline_policies = group.get_inline_policies(group_name) assert len(latest_inline_policies) == 0 + + + +@pytest.fixture(scope="module") +def test_user_for_group(): + """Creates a test IAM user for group membership tests.""" + from e2e import user + user_name = random_suffix_name("test-user-for-group", 24) + user.create_test_user(user_name) + user.wait_until_exists(user_name) + yield user_name + user.delete_test_user(user_name) + + +@pytest.fixture(scope="module") +def group_with_users(test_user_for_group): + """Creates a group with a user already added.""" + group_name = random_suffix_name("group-with-users", 24) + user_name = test_user_for_group + + replacements = REPLACEMENT_VALUES.copy() + replacements['GROUP_NAME'] = group_name + replacements['USER_NAME'] = user_name + + resource_data = load_resource( + "group_with_users", + additional_replacements=replacements, + ) + + ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, GROUP_RESOURCE_PLURAL, + group_name, namespace="default", + ) + k8s.create_custom_resource(ref, resource_data) + cr = k8s.wait_resource_consumed_by_controller(ref) + + group.wait_until_exists(group_name) + + assert cr is not None + assert k8s.get_resource_exists(ref) + + yield (ref, cr, user_name) + + _, deleted = k8s.delete_custom_resource( + ref, + period_length=DELETE_WAIT_AFTER_SECONDS, + ) + assert deleted + + group.wait_until_deleted(group_name) + + +@service_marker +class TestGroupUsers: + def test_create_group_with_users(self, group_with_users): + """Test creating a group with users specified in the spec.""" + ref, res, user_name = group_with_users + group_name = ref.name + + time.sleep(CHECK_STATUS_WAIT_SECONDS) + + condition.assert_synced(ref) + + # Verify user is in the group via AWS API + latest_users = group.get_users(group_name) + assert latest_users is not None + assert user_name in latest_users + + def test_add_users_to_group(self, simple_group, test_user_for_group): + """Test adding users to an existing group.""" + ref, res = simple_group + group_name = ref.name + user_name = test_user_for_group + + time.sleep(CHECK_STATUS_WAIT_SECONDS) + + # Verify group starts with no users + latest_users = group.get_users(group_name) + assert latest_users is not None + assert user_name not in latest_users + + # Add user to the group + updates = { + "spec": { + "users": [user_name], + }, + } + k8s.patch_custom_resource(ref, updates) + time.sleep(MODIFY_WAIT_AFTER_SECONDS) + + # Verify user is now in the group + latest_users = group.get_users(group_name) + assert latest_users is not None + assert user_name in latest_users + + # Verify the CR spec reflects the users + cr = k8s.get_resource(ref) + assert cr is not None + assert 'spec' in cr + assert 'users' in cr['spec'] + assert user_name in cr['spec']['users'] + + def test_remove_users_from_group(self, simple_group, test_user_for_group): + """Test removing users from a group.""" + ref, res = simple_group + group_name = ref.name + user_name = test_user_for_group + + time.sleep(CHECK_STATUS_WAIT_SECONDS) + + # First add a user to the group + updates = { + "spec": { + "users": [user_name], + }, + } + k8s.patch_custom_resource(ref, updates) + time.sleep(MODIFY_WAIT_AFTER_SECONDS) + + # Verify user is in the group + latest_users = group.get_users(group_name) + assert latest_users is not None + assert user_name in latest_users + + # Remove user from the group + updates = { + "spec": { + "users": [], + }, + } + k8s.patch_custom_resource(ref, updates) + time.sleep(MODIFY_WAIT_AFTER_SECONDS) + + # Verify user is no longer in the group + latest_users = group.get_users(group_name) + assert latest_users is not None + assert user_name not in latest_users + + def test_delete_group_with_users(self, test_user_for_group): + """Test that deleting a group with users removes users first.""" + group_name = random_suffix_name("group-delete-test", 24) + user_name = test_user_for_group + + replacements = REPLACEMENT_VALUES.copy() + replacements['GROUP_NAME'] = group_name + replacements['USER_NAME'] = user_name + + resource_data = load_resource( + "group_with_users", + additional_replacements=replacements, + ) + + ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, GROUP_RESOURCE_PLURAL, + group_name, namespace="default", + ) + k8s.create_custom_resource(ref, resource_data) + cr = k8s.wait_resource_consumed_by_controller(ref) + + group.wait_until_exists(group_name) + + assert cr is not None + assert k8s.get_resource_exists(ref) + + time.sleep(CHECK_STATUS_WAIT_SECONDS) + + # Verify user is in the group + latest_users = group.get_users(group_name) + assert latest_users is not None + assert user_name in latest_users + + # Delete the group + _, deleted = k8s.delete_custom_resource( + ref, + period_length=DELETE_WAIT_AFTER_SECONDS, + ) + assert deleted + + group.wait_until_deleted(group_name) + + # Verify the group no longer exists + assert group.get(group_name) is None diff --git a/test/e2e/user.py b/test/e2e/user.py index b1ab2c3..78b4701 100644 --- a/test/e2e/user.py +++ b/test/e2e/user.py @@ -149,3 +149,31 @@ def get_inline_policies(user_name): return policies except c.exceptions.NoSuchEntityException: return None + + +def create_test_user(user_name): + """Creates a test IAM user for group membership tests. + + Returns the created user dict, or None if creation failed. + """ + c = boto3.client('iam') + try: + resp = c.create_user(UserName=user_name) + return resp['User'] + except c.exceptions.EntityAlreadyExistsException: + return get(user_name) + + +def delete_test_user(user_name): + """Deletes a test IAM user. + + Returns True if deletion succeeded, False otherwise. + """ + c = boto3.client('iam') + try: + c.delete_user(UserName=user_name) + return True + except c.exceptions.NoSuchEntityException: + return True + except Exception: + return False