From 7987a29e583b16f32e8e7bccbba825d0e8dd1e19 Mon Sep 17 00:00:00 2001 From: Mostafa Anas Date: Fri, 19 Jun 2026 14:01:30 +0100 Subject: [PATCH 1/5] Add RolePolicyAttachment resource for IAM policy-role binding Problem: ACK IAM controller lacks a first-class resource to attach policies to existing roles, forcing users to rely on out-of-band tooling for role-policy relationships. Solution: introduce RolePolicyAttachment with generated API/manager/CRD artifacts, controller wiring, RBAC updates, and e2e coverage for reference/direct attachment flows. Validation: manual cluster tests on dev34 confirmed attach and detach lifecycle for ACK-managed and existing AWS IAM roles/policies; compile checks pass. --- apis/v1alpha1/role_policy_attachment.go | 59 ++++ apis/v1alpha1/zz_generated.deepcopy.go | 130 +++++++ cmd/controller/main.go | 1 + ...ervices.k8s.aws_rolepolicyattachments.yaml | 157 +++++++++ config/rbac/cluster-role-controller.yaml | 2 + examples/rolepolicyattachment-direct.yaml | 12 + examples/rolepolicyattachment-with-refs.yaml | 59 ++++ ...ervices.k8s.aws_rolepolicyattachments.yaml | 157 +++++++++ helm/templates/_helpers.tpl | 2 + pkg/resource/role_policy_attachment/delta.go | 44 +++ .../role_policy_attachment/descriptor.go | 99 ++++++ .../role_policy_attachment/identifiers.go | 48 +++ .../role_policy_attachment/manager.go | 220 ++++++++++++ .../role_policy_attachment/manager_factory.go | 80 +++++ .../role_policy_attachment/references.go | 174 ++++++++++ .../role_policy_attachment/resource.go | 93 +++++ pkg/resource/role_policy_attachment/sdk.go | 246 ++++++++++++++ test/e2e/common/types.py | 1 + .../role_policy_attachment_referring.yaml | 11 + test/e2e/tests/test_role_policy_attachment.py | 318 ++++++++++++++++++ 20 files changed, 1913 insertions(+) create mode 100644 apis/v1alpha1/role_policy_attachment.go create mode 100644 config/crd/bases/iam.services.k8s.aws_rolepolicyattachments.yaml create mode 100644 examples/rolepolicyattachment-direct.yaml create mode 100644 examples/rolepolicyattachment-with-refs.yaml create mode 100644 helm/crds/iam.services.k8s.aws_rolepolicyattachments.yaml create mode 100644 pkg/resource/role_policy_attachment/delta.go create mode 100644 pkg/resource/role_policy_attachment/descriptor.go create mode 100644 pkg/resource/role_policy_attachment/identifiers.go create mode 100644 pkg/resource/role_policy_attachment/manager.go create mode 100644 pkg/resource/role_policy_attachment/manager_factory.go create mode 100644 pkg/resource/role_policy_attachment/references.go create mode 100644 pkg/resource/role_policy_attachment/resource.go create mode 100644 pkg/resource/role_policy_attachment/sdk.go create mode 100644 test/e2e/resources/role_policy_attachment_referring.yaml create mode 100644 test/e2e/tests/test_role_policy_attachment.py diff --git a/apis/v1alpha1/role_policy_attachment.go b/apis/v1alpha1/role_policy_attachment.go new file mode 100644 index 0000000..6b9295d --- /dev/null +++ b/apis/v1alpha1/role_policy_attachment.go @@ -0,0 +1,59 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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 v1alpha1 + +import ( + ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// RolePolicyAttachmentSpec defines the desired state of RolePolicyAttachment. +type RolePolicyAttachmentSpec struct { + PolicyARN *string `json:"policyARN,omitempty"` + PolicyRef *ackv1alpha1.AWSResourceReferenceWrapper `json:"policyRef,omitempty"` + RoleName *string `json:"roleName,omitempty"` + RoleRef *ackv1alpha1.AWSResourceReferenceWrapper `json:"roleRef,omitempty"` +} + +// RolePolicyAttachmentStatus defines the observed state of RolePolicyAttachment. +type RolePolicyAttachmentStatus struct { + // +kubebuilder:validation:Optional + ACKResourceMetadata *ackv1alpha1.ResourceMetadata `json:"ackResourceMetadata"` + // +kubebuilder:validation:Optional + Conditions []*ackv1alpha1.Condition `json:"conditions"` + // +kubebuilder:validation:Optional + Attached *bool `json:"attached,omitempty"` +} + +// RolePolicyAttachment is the Schema for the RolePolicyAttachments API. +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +type RolePolicyAttachment struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec RolePolicyAttachmentSpec `json:"spec,omitempty"` + Status RolePolicyAttachmentStatus `json:"status,omitempty"` +} + +// RolePolicyAttachmentList contains a list of RolePolicyAttachment. +// +kubebuilder:object:root=true +type RolePolicyAttachmentList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []RolePolicyAttachment `json:"items"` +} + +func init() { + SchemeBuilder.Register(&RolePolicyAttachment{}, &RolePolicyAttachmentList{}) +} diff --git a/apis/v1alpha1/zz_generated.deepcopy.go b/apis/v1alpha1/zz_generated.deepcopy.go index 1c68e4b..9c54b15 100644 --- a/apis/v1alpha1/zz_generated.deepcopy.go +++ b/apis/v1alpha1/zz_generated.deepcopy.go @@ -1624,6 +1624,136 @@ func (in *RoleList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RolePolicyAttachment) DeepCopyInto(out *RolePolicyAttachment) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RolePolicyAttachment. +func (in *RolePolicyAttachment) DeepCopy() *RolePolicyAttachment { + if in == nil { + return nil + } + out := new(RolePolicyAttachment) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RolePolicyAttachment) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RolePolicyAttachmentList) DeepCopyInto(out *RolePolicyAttachmentList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]RolePolicyAttachment, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RolePolicyAttachmentList. +func (in *RolePolicyAttachmentList) DeepCopy() *RolePolicyAttachmentList { + if in == nil { + return nil + } + out := new(RolePolicyAttachmentList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RolePolicyAttachmentList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RolePolicyAttachmentSpec) DeepCopyInto(out *RolePolicyAttachmentSpec) { + *out = *in + if in.PolicyARN != nil { + in, out := &in.PolicyARN, &out.PolicyARN + *out = new(string) + **out = **in + } + if in.PolicyRef != nil { + in, out := &in.PolicyRef, &out.PolicyRef + *out = new(corev1alpha1.AWSResourceReferenceWrapper) + (*in).DeepCopyInto(*out) + } + if in.RoleName != nil { + in, out := &in.RoleName, &out.RoleName + *out = new(string) + **out = **in + } + if in.RoleRef != nil { + in, out := &in.RoleRef, &out.RoleRef + *out = new(corev1alpha1.AWSResourceReferenceWrapper) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RolePolicyAttachmentSpec. +func (in *RolePolicyAttachmentSpec) DeepCopy() *RolePolicyAttachmentSpec { + if in == nil { + return nil + } + out := new(RolePolicyAttachmentSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RolePolicyAttachmentStatus) DeepCopyInto(out *RolePolicyAttachmentStatus) { + *out = *in + if in.ACKResourceMetadata != nil { + in, out := &in.ACKResourceMetadata, &out.ACKResourceMetadata + *out = new(corev1alpha1.ResourceMetadata) + (*in).DeepCopyInto(*out) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]*corev1alpha1.Condition, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(corev1alpha1.Condition) + (*in).DeepCopyInto(*out) + } + } + } + if in.Attached != nil { + in, out := &in.Attached, &out.Attached + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RolePolicyAttachmentStatus. +func (in *RolePolicyAttachmentStatus) DeepCopy() *RolePolicyAttachmentStatus { + if in == nil { + return nil + } + out := new(RolePolicyAttachmentStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RoleSpec) DeepCopyInto(out *RoleSpec) { *out = *in diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 90b7f12..ecd6748 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -44,6 +44,7 @@ import ( _ "github.com/aws-controllers-k8s/iam-controller/pkg/resource/open_id_connect_provider" _ "github.com/aws-controllers-k8s/iam-controller/pkg/resource/policy" _ "github.com/aws-controllers-k8s/iam-controller/pkg/resource/role" + _ "github.com/aws-controllers-k8s/iam-controller/pkg/resource/role_policy_attachment" _ "github.com/aws-controllers-k8s/iam-controller/pkg/resource/service_linked_role" _ "github.com/aws-controllers-k8s/iam-controller/pkg/resource/user" diff --git a/config/crd/bases/iam.services.k8s.aws_rolepolicyattachments.yaml b/config/crd/bases/iam.services.k8s.aws_rolepolicyattachments.yaml new file mode 100644 index 0000000..a2e11a7 --- /dev/null +++ b/config/crd/bases/iam.services.k8s.aws_rolepolicyattachments.yaml @@ -0,0 +1,157 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: rolepolicyattachments.iam.services.k8s.aws +spec: + group: iam.services.k8s.aws + names: + kind: RolePolicyAttachment + listKind: RolePolicyAttachmentList + plural: rolepolicyattachments + singular: rolepolicyattachment + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: RolePolicyAttachment is the Schema for the RolePolicyAttachments + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: RolePolicyAttachmentSpec defines the desired state of RolePolicyAttachment. + properties: + policyARN: + type: string + policyRef: + 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 + roleName: + type: string + roleRef: + 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: object + status: + description: RolePolicyAttachmentStatus defines the observed state of + RolePolicyAttachment. + properties: + ackResourceMetadata: + description: |- + ResourceMetadata is common to all custom resources (CRs) managed by an ACK + service controller. It is contained in the CR's `Status` member field and + comprises various status and identifier fields useful to ACK for tracking + state changes between Kubernetes and the backend AWS service API + properties: + arn: + description: |- + ARN is the Amazon Resource Name for the resource. This is a + globally-unique identifier and is set only by the ACK service controller + once the controller has orchestrated the creation of the resource OR + when it has verified that an "adopted" resource (a resource where the + ARN annotation was set by the Kubernetes user on the CR) exists and + matches the supplied CR's Spec field values. + https://github.com/aws/aws-controllers-k8s/issues/270 + type: string + ownerAccountID: + description: |- + OwnerAccountID is the AWS Account ID of the account that owns the + backend AWS service API resource. + type: string + partition: + description: Partition is the AWS partition in which the resource + exists or will exist + type: string + region: + description: Region is the AWS region in which the resource exists + or will exist. + type: string + required: + - ownerAccountID + - region + type: object + attached: + type: boolean + conditions: + items: + description: |- + Condition is the common struct used by all CRDs managed by ACK service + controllers to indicate terminal states of the CR and its backend AWS + service API resource + properties: + lastTransitionTime: + description: Last time the condition transitioned from one status + to another. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type is the type of the Condition + type: string + required: + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/rbac/cluster-role-controller.yaml b/config/rbac/cluster-role-controller.yaml index 4b56f4f..f70bdc1 100644 --- a/config/rbac/cluster-role-controller.yaml +++ b/config/rbac/cluster-role-controller.yaml @@ -29,6 +29,7 @@ rules: - instanceprofiles - openidconnectproviders - policies + - rolepolicyattachments - roles - servicelinkedroles - users @@ -47,6 +48,7 @@ rules: - instanceprofiles/status - openidconnectproviders/status - policies/status + - rolepolicyattachments/status - roles/status - servicelinkedroles/status - users/status diff --git a/examples/rolepolicyattachment-direct.yaml b/examples/rolepolicyattachment-direct.yaml new file mode 100644 index 0000000..47c36fe --- /dev/null +++ b/examples/rolepolicyattachment-direct.yaml @@ -0,0 +1,12 @@ +# Example 2: RolePolicyAttachment using direct ARN/name references +# Use this when Role and Policy already exist in AWS and are not managed by ACK. + +--- +apiVersion: iam.services.k8s.aws/v1alpha1 +kind: RolePolicyAttachment +metadata: + name: attach-existing-lambda-execution +spec: + # Direct reference to existing AWS resources + roleName: lambda-execution-role + policyARN: arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole diff --git a/examples/rolepolicyattachment-with-refs.yaml b/examples/rolepolicyattachment-with-refs.yaml new file mode 100644 index 0000000..bfe5a1f --- /dev/null +++ b/examples/rolepolicyattachment-with-refs.yaml @@ -0,0 +1,59 @@ +# Example 1: RolePolicyAttachment using Kubernetes references +# This is the recommended pattern when you want to manage Role and Policy as ACK CRs too. + +--- +apiVersion: iam.services.k8s.aws/v1alpha1 +kind: Role +metadata: + name: my-app-role +spec: + name: my-app-role + assumeRolePolicyDocument: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + } + +--- +apiVersion: iam.services.k8s.aws/v1alpha1 +kind: Policy +metadata: + name: s3-read-only +spec: + name: s3-read-only-policy + policyDocument: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:ListBucket", + "s3:GetObject" + ], + "Resource": "*" + } + ] + } + +--- +apiVersion: iam.services.k8s.aws/v1alpha1 +kind: RolePolicyAttachment +metadata: + name: attach-s3-read +spec: + # Use references to other ACK resources + roleRef: + from: + name: my-app-role + policyRef: + from: + name: s3-read-only diff --git a/helm/crds/iam.services.k8s.aws_rolepolicyattachments.yaml b/helm/crds/iam.services.k8s.aws_rolepolicyattachments.yaml new file mode 100644 index 0000000..a2e11a7 --- /dev/null +++ b/helm/crds/iam.services.k8s.aws_rolepolicyattachments.yaml @@ -0,0 +1,157 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: rolepolicyattachments.iam.services.k8s.aws +spec: + group: iam.services.k8s.aws + names: + kind: RolePolicyAttachment + listKind: RolePolicyAttachmentList + plural: rolepolicyattachments + singular: rolepolicyattachment + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: RolePolicyAttachment is the Schema for the RolePolicyAttachments + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: RolePolicyAttachmentSpec defines the desired state of RolePolicyAttachment. + properties: + policyARN: + type: string + policyRef: + 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 + roleName: + type: string + roleRef: + 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: object + status: + description: RolePolicyAttachmentStatus defines the observed state of + RolePolicyAttachment. + properties: + ackResourceMetadata: + description: |- + ResourceMetadata is common to all custom resources (CRs) managed by an ACK + service controller. It is contained in the CR's `Status` member field and + comprises various status and identifier fields useful to ACK for tracking + state changes between Kubernetes and the backend AWS service API + properties: + arn: + description: |- + ARN is the Amazon Resource Name for the resource. This is a + globally-unique identifier and is set only by the ACK service controller + once the controller has orchestrated the creation of the resource OR + when it has verified that an "adopted" resource (a resource where the + ARN annotation was set by the Kubernetes user on the CR) exists and + matches the supplied CR's Spec field values. + https://github.com/aws/aws-controllers-k8s/issues/270 + type: string + ownerAccountID: + description: |- + OwnerAccountID is the AWS Account ID of the account that owns the + backend AWS service API resource. + type: string + partition: + description: Partition is the AWS partition in which the resource + exists or will exist + type: string + region: + description: Region is the AWS region in which the resource exists + or will exist. + type: string + required: + - ownerAccountID + - region + type: object + attached: + type: boolean + conditions: + items: + description: |- + Condition is the common struct used by all CRDs managed by ACK service + controllers to indicate terminal states of the CR and its backend AWS + service API resource + properties: + lastTransitionTime: + description: Last time the condition transitioned from one status + to another. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type is the type of the Condition + type: string + required: + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl index 665626e..e2ba24f 100644 --- a/helm/templates/_helpers.tpl +++ b/helm/templates/_helpers.tpl @@ -76,6 +76,7 @@ rules: - instanceprofiles - openidconnectproviders - policies + - rolepolicyattachments - roles - servicelinkedroles - users @@ -94,6 +95,7 @@ rules: - instanceprofiles/status - openidconnectproviders/status - policies/status + - rolepolicyattachments/status - roles/status - servicelinkedroles/status - users/status diff --git a/pkg/resource/role_policy_attachment/delta.go b/pkg/resource/role_policy_attachment/delta.go new file mode 100644 index 0000000..6c90b7e --- /dev/null +++ b/pkg/resource/role_policy_attachment/delta.go @@ -0,0 +1,44 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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 role_policy_attachment + +import ( + ackcompare "github.com/aws-controllers-k8s/runtime/pkg/compare" + "k8s.io/apimachinery/pkg/api/equality" +) + +func newResourceDelta(a *resource, b *resource) *ackcompare.Delta { + delta := ackcompare.NewDelta() + if (a == nil && b != nil) || (a != nil && b == nil) { + delta.Add("", a, b) + return delta + } + if ackcompare.HasNilDifference(a.ko.Spec.RoleName, b.ko.Spec.RoleName) { + delta.Add("Spec.RoleName", a.ko.Spec.RoleName, b.ko.Spec.RoleName) + } else if a.ko.Spec.RoleName != nil && b.ko.Spec.RoleName != nil && *a.ko.Spec.RoleName != *b.ko.Spec.RoleName { + delta.Add("Spec.RoleName", a.ko.Spec.RoleName, b.ko.Spec.RoleName) + } + if ackcompare.HasNilDifference(a.ko.Spec.PolicyARN, b.ko.Spec.PolicyARN) { + delta.Add("Spec.PolicyARN", a.ko.Spec.PolicyARN, b.ko.Spec.PolicyARN) + } else if a.ko.Spec.PolicyARN != nil && b.ko.Spec.PolicyARN != nil && *a.ko.Spec.PolicyARN != *b.ko.Spec.PolicyARN { + delta.Add("Spec.PolicyARN", a.ko.Spec.PolicyARN, b.ko.Spec.PolicyARN) + } + if !equality.Semantic.DeepEqual(a.ko.Spec.RoleRef, b.ko.Spec.RoleRef) { + delta.Add("Spec.RoleRef", a.ko.Spec.RoleRef, b.ko.Spec.RoleRef) + } + if !equality.Semantic.DeepEqual(a.ko.Spec.PolicyRef, b.ko.Spec.PolicyRef) { + delta.Add("Spec.PolicyRef", a.ko.Spec.PolicyRef, b.ko.Spec.PolicyRef) + } + return delta +} diff --git a/pkg/resource/role_policy_attachment/descriptor.go b/pkg/resource/role_policy_attachment/descriptor.go new file mode 100644 index 0000000..b0d7ed3 --- /dev/null +++ b/pkg/resource/role_policy_attachment/descriptor.go @@ -0,0 +1,99 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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 role_policy_attachment + +import ( + ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" + ackcompare "github.com/aws-controllers-k8s/runtime/pkg/compare" + acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + rtclient "sigs.k8s.io/controller-runtime/pkg/client" + k8sctrlutil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" +) + +const ( + FinalizerString = "finalizers.iam.services.k8s.aws/RolePolicyAttachment" +) + +var ( + GroupVersionResource = svcapitypes.GroupVersion.WithResource("rolepolicyattachments") + GroupKind = metav1.GroupKind{Group: "iam.services.k8s.aws", Kind: "RolePolicyAttachment"} +) + +type resourceDescriptor struct{} + +func (d *resourceDescriptor) GroupVersionKind() schema.GroupVersionKind { + return svcapitypes.GroupVersion.WithKind(GroupKind.Kind) +} + +func (d *resourceDescriptor) EmptyRuntimeObject() rtclient.Object { + return &svcapitypes.RolePolicyAttachment{} +} + +func (d *resourceDescriptor) ResourceFromRuntimeObject(obj rtclient.Object) acktypes.AWSResource { + return &resource{ko: obj.(*svcapitypes.RolePolicyAttachment)} +} + +func (d *resourceDescriptor) Delta(a, b acktypes.AWSResource) *ackcompare.Delta { + return newResourceDelta(a.(*resource), b.(*resource)) +} + +func (d *resourceDescriptor) IsManaged(res acktypes.AWSResource) bool { + obj := res.RuntimeObject() + if obj == nil { + panic("nil RuntimeMetaObject in AWSResource") + } + return containsFinalizer(obj, FinalizerString) +} + +func containsFinalizer(obj rtclient.Object, finalizer string) bool { + for _, existing := range obj.GetFinalizers() { + if existing == finalizer { + return true + } + } + return false +} + +func (d *resourceDescriptor) MarkManaged(res acktypes.AWSResource) { + obj := res.RuntimeObject() + if obj == nil { + panic("nil RuntimeMetaObject in AWSResource") + } + k8sctrlutil.AddFinalizer(obj, FinalizerString) +} + +func (d *resourceDescriptor) MarkUnmanaged(res acktypes.AWSResource) { + obj := res.RuntimeObject() + if obj == nil { + panic("nil RuntimeMetaObject in AWSResource") + } + k8sctrlutil.RemoveFinalizer(obj, FinalizerString) +} + +func (d *resourceDescriptor) MarkAdopted(res acktypes.AWSResource) { + obj := res.RuntimeObject() + if obj == nil { + panic("nil RuntimeObject in AWSResource") + } + annotations := obj.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[ackv1alpha1.AnnotationAdopted] = "true" + obj.SetAnnotations(annotations) +} diff --git a/pkg/resource/role_policy_attachment/identifiers.go b/pkg/resource/role_policy_attachment/identifiers.go new file mode 100644 index 0000000..66a046a --- /dev/null +++ b/pkg/resource/role_policy_attachment/identifiers.go @@ -0,0 +1,48 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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 role_policy_attachment + +import ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" + +type resourceIdentifiers struct { + meta *ackv1alpha1.ResourceMetadata +} + +func (ri *resourceIdentifiers) ARN() *ackv1alpha1.AWSResourceName { + if ri.meta != nil { + return ri.meta.ARN + } + return nil +} + +func (ri *resourceIdentifiers) OwnerAccountID() *ackv1alpha1.AWSAccountID { + if ri.meta != nil { + return ri.meta.OwnerAccountID + } + return nil +} + +func (ri *resourceIdentifiers) Region() *ackv1alpha1.AWSRegion { + if ri.meta != nil { + return ri.meta.Region + } + return nil +} + +func (ri *resourceIdentifiers) Partition() *ackv1alpha1.AWSPartition { + if ri.meta != nil { + return ri.meta.Partition + } + return nil +} diff --git a/pkg/resource/role_policy_attachment/manager.go b/pkg/resource/role_policy_attachment/manager.go new file mode 100644 index 0000000..90bcc75 --- /dev/null +++ b/pkg/resource/role_policy_attachment/manager.go @@ -0,0 +1,220 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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 role_policy_attachment + +import ( + "context" + "fmt" + "time" + + ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" + ackcompare "github.com/aws-controllers-k8s/runtime/pkg/compare" + ackcondition "github.com/aws-controllers-k8s/runtime/pkg/condition" + ackcfg "github.com/aws-controllers-k8s/runtime/pkg/config" + ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" + ackmetrics "github.com/aws-controllers-k8s/runtime/pkg/metrics" + ackrequeue "github.com/aws-controllers-k8s/runtime/pkg/requeue" + acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" + "github.com/aws/aws-sdk-go-v2/aws" + svcsdk "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" +) + +// +kubebuilder:rbac:groups=iam.services.k8s.aws,resources=rolepolicyattachments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=iam.services.k8s.aws,resources=rolepolicyattachments/status,verbs=get;update;patch + +var lateInitializeFieldNames = []string{} + +type resourceManager struct { + cfg ackcfg.Config + clientcfg aws.Config + log logr.Logger + metrics *ackmetrics.Metrics + rr acktypes.Reconciler + awsAccountID ackv1alpha1.AWSAccountID + awsRegion ackv1alpha1.AWSRegion + awsPartition ackv1alpha1.AWSPartition + sdkapi *svcsdk.Client +} + +func (rm *resourceManager) concreteResource(res acktypes.AWSResource) *resource { + return res.(*resource) +} + +func (rm *resourceManager) ReadOne(ctx context.Context, res acktypes.AWSResource) (acktypes.AWSResource, error) { + r := rm.concreteResource(res) + if r.ko == nil { + panic("resource manager's ReadOne() method received resource with nil CR object") + } + observed, err := rm.sdkFind(ctx, r) + if err != nil { + if observed != nil { + return rm.onError(observed, err) + } + return rm.onError(r, err) + } + return rm.onSuccess(observed) +} + +func (rm *resourceManager) Create(ctx context.Context, res acktypes.AWSResource) (acktypes.AWSResource, error) { + r := rm.concreteResource(res) + if r.ko == nil { + panic("resource manager's Create() method received resource with nil CR object") + } + created, err := rm.sdkCreate(ctx, r) + if err != nil { + if created != nil { + return rm.onError(created, err) + } + return rm.onError(r, err) + } + return rm.onSuccess(created) +} + +func (rm *resourceManager) Update( + ctx context.Context, + resDesired acktypes.AWSResource, + resLatest acktypes.AWSResource, + delta *ackcompare.Delta, +) (acktypes.AWSResource, error) { + desired := rm.concreteResource(resDesired) + latest := rm.concreteResource(resLatest) + if desired.ko == nil || latest.ko == nil { + panic("resource manager's Update() method received resource with nil CR object") + } + updated, err := rm.sdkUpdate(ctx, desired, latest, delta) + if err != nil { + if updated != nil { + return rm.onError(updated, err) + } + return rm.onError(latest, err) + } + return rm.onSuccess(updated) +} + +func (rm *resourceManager) Delete(ctx context.Context, res acktypes.AWSResource) (acktypes.AWSResource, error) { + r := rm.concreteResource(res) + if r.ko == nil { + panic("resource manager's Delete() method received resource with nil CR object") + } + observed, err := rm.sdkDelete(ctx, r) + if err != nil { + if observed != nil { + return rm.onError(observed, err) + } + return rm.onError(r, err) + } + return rm.onSuccess(observed) +} + +func (rm *resourceManager) ARNFromName(name string) string { + return fmt.Sprintf("arn:%s:iam:%s:%s:%s", rm.awsPartition, rm.awsRegion, rm.awsAccountID, name) +} + +func (rm *resourceManager) LateInitialize(ctx context.Context, latest acktypes.AWSResource) (acktypes.AWSResource, error) { + if len(lateInitializeFieldNames) == 0 { + return latest, nil + } + latestCopy := latest.DeepCopy() + lateInitConditionReason := "" + lateInitConditionMessage := "" + observed, err := rm.ReadOne(ctx, latestCopy) + if err != nil { + lateInitConditionMessage = "Unable to complete Read operation required for late initialization" + lateInitConditionReason = "Late Initialization Failure" + ackcondition.SetLateInitialized(latestCopy, corev1.ConditionFalse, &lateInitConditionMessage, &lateInitConditionReason) + ackcondition.SetSynced(latestCopy, corev1.ConditionFalse, nil, nil) + return latestCopy, err + } + lateInitializedRes := rm.lateInitializeFromReadOneOutput(observed, latestCopy) + if rm.incompleteLateInitialization(lateInitializedRes) { + lateInitConditionMessage = "Late initialization did not complete, requeuing with delay of 5 seconds" + lateInitConditionReason = "Delayed Late Initialization" + ackcondition.SetLateInitialized(lateInitializedRes, corev1.ConditionFalse, &lateInitConditionMessage, &lateInitConditionReason) + ackcondition.SetSynced(lateInitializedRes, corev1.ConditionFalse, nil, nil) + return lateInitializedRes, ackrequeue.NeededAfter(nil, 5*time.Second) + } + lateInitConditionMessage = "Late initialization successful" + lateInitConditionReason = "Late initialization successful" + ackcondition.SetLateInitialized(lateInitializedRes, corev1.ConditionTrue, &lateInitConditionMessage, &lateInitConditionReason) + return lateInitializedRes, nil +} + +func (rm *resourceManager) incompleteLateInitialization(res acktypes.AWSResource) bool { + return false +} + +func (rm *resourceManager) lateInitializeFromReadOneOutput(observed acktypes.AWSResource, latest acktypes.AWSResource) acktypes.AWSResource { + return latest +} + +func (rm *resourceManager) IsSynced(ctx context.Context, res acktypes.AWSResource) (bool, error) { + return true, nil +} + +func (rm *resourceManager) EnsureTags(ctx context.Context, res acktypes.AWSResource, md acktypes.ServiceControllerMetadata) error { + return nil +} + +func (rm *resourceManager) FilterSystemTags(res acktypes.AWSResource, systemTags []string) {} + +func newResourceManager( + cfg ackcfg.Config, + clientcfg aws.Config, + log logr.Logger, + metrics *ackmetrics.Metrics, + rr acktypes.Reconciler, + id ackv1alpha1.AWSAccountID, + region ackv1alpha1.AWSRegion, +) (*resourceManager, error) { + return &resourceManager{ + cfg: cfg, + clientcfg: clientcfg, + log: log, + metrics: metrics, + rr: rr, + awsAccountID: id, + awsRegion: region, + awsPartition: ackv1alpha1.AWSPartition(cfg.Partition), + sdkapi: svcsdk.NewFromConfig(clientcfg), + }, nil +} + +func (rm *resourceManager) onError(r *resource, err error) (acktypes.AWSResource, error) { + if r == nil { + return nil, err + } + r1, updated := rm.updateConditions(r, false, err) + if !updated { + return r, err + } + for _, condition := range r1.Conditions() { + if condition.Type == ackv1alpha1.ConditionTypeTerminal && condition.Status == corev1.ConditionTrue { + return r1, ackerr.Terminal + } + } + return r1, err +} + +func (rm *resourceManager) onSuccess(r *resource) (acktypes.AWSResource, error) { + if r == nil { + return nil, nil + } + r1, updated := rm.updateConditions(r, true, nil) + if !updated { + return r, nil + } + return r1, nil +} diff --git a/pkg/resource/role_policy_attachment/manager_factory.go b/pkg/resource/role_policy_attachment/manager_factory.go new file mode 100644 index 0000000..caadd7e --- /dev/null +++ b/pkg/resource/role_policy_attachment/manager_factory.go @@ -0,0 +1,80 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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 role_policy_attachment + +import ( + "fmt" + "sync" + + ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" + ackcfg "github.com/aws-controllers-k8s/runtime/pkg/config" + ackmetrics "github.com/aws-controllers-k8s/runtime/pkg/metrics" + acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/go-logr/logr" + + svcresource "github.com/aws-controllers-k8s/iam-controller/pkg/resource" +) + +type resourceManagerFactory struct { + sync.RWMutex + rmCache map[string]*resourceManager +} + +func (f *resourceManagerFactory) ResourceDescriptor() acktypes.AWSResourceDescriptor { + return &resourceDescriptor{} +} + +func (f *resourceManagerFactory) ManagerFor( + cfg ackcfg.Config, + clientcfg aws.Config, + log logr.Logger, + metrics *ackmetrics.Metrics, + rr acktypes.Reconciler, + id ackv1alpha1.AWSAccountID, + region ackv1alpha1.AWSRegion, + roleARN ackv1alpha1.AWSResourceName, +) (acktypes.AWSResourceManager, error) { + rmID := fmt.Sprintf("%s/%s/%s", id, region, roleARN) + f.RLock() + rm, found := f.rmCache[rmID] + f.RUnlock() + if found { + return rm, nil + } + f.Lock() + defer f.Unlock() + rm, err := newResourceManager(cfg, clientcfg, log, metrics, rr, id, region) + if err != nil { + return nil, err + } + f.rmCache[rmID] = rm + return rm, nil +} + +func (f *resourceManagerFactory) IsAdoptable() bool { + return true +} + +func (f *resourceManagerFactory) RequeueOnSuccessSeconds() int { + return 0 +} + +func newResourceManagerFactory() *resourceManagerFactory { + return &resourceManagerFactory{rmCache: map[string]*resourceManager{}} +} + +func init() { + svcresource.RegisterManagerFactory(newResourceManagerFactory()) +} diff --git a/pkg/resource/role_policy_attachment/references.go b/pkg/resource/role_policy_attachment/references.go new file mode 100644 index 0000000..ee19739 --- /dev/null +++ b/pkg/resource/role_policy_attachment/references.go @@ -0,0 +1,174 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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 role_policy_attachment + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" + ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" + acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" + + svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" +) + +func (rm *resourceManager) ClearResolvedReferences(res acktypes.AWSResource) acktypes.AWSResource { + ko := rm.concreteResource(res).ko.DeepCopy() + if ko.Spec.RoleRef != nil { + ko.Spec.RoleName = nil + } + if ko.Spec.PolicyRef != nil { + ko.Spec.PolicyARN = nil + } + return &resource{ko} +} + +func (rm *resourceManager) ResolveReferences( + ctx context.Context, + apiReader client.Reader, + res acktypes.AWSResource, +) (acktypes.AWSResource, bool, error) { + ko := rm.concreteResource(res).ko + resourceHasReferences := false + if err := validateReferenceFields(ko); err != nil { + return &resource{ko}, resourceHasReferences, err + } + if fieldHasReferences, err := rm.resolveReferenceForRole(ctx, apiReader, ko); err != nil { + return &resource{ko}, (resourceHasReferences || fieldHasReferences), err + } else { + resourceHasReferences = resourceHasReferences || fieldHasReferences + } + if fieldHasReferences, err := rm.resolveReferenceForPolicy(ctx, apiReader, ko); err != nil { + return &resource{ko}, (resourceHasReferences || fieldHasReferences), err + } else { + resourceHasReferences = resourceHasReferences || fieldHasReferences + } + return &resource{ko}, resourceHasReferences, nil +} + +func validateReferenceFields(ko *svcapitypes.RolePolicyAttachment) error { + if ko.Spec.RoleRef != nil && ko.Spec.RoleName != nil { + return ackerr.ResourceReferenceAndIDNotSupportedFor("RoleName", "RoleRef") + } + if ko.Spec.PolicyRef != nil && ko.Spec.PolicyARN != nil { + return ackerr.ResourceReferenceAndIDNotSupportedFor("PolicyARN", "PolicyRef") + } + return nil +} + +func (rm *resourceManager) resolveReferenceForRole( + ctx context.Context, + apiReader client.Reader, + ko *svcapitypes.RolePolicyAttachment, +) (hasReferences bool, err error) { + if ko.Spec.RoleRef == nil || ko.Spec.RoleRef.From == nil { + return false, nil + } + hasReferences = true + arr := ko.Spec.RoleRef.From + if arr.Name == nil || *arr.Name == "" { + return hasReferences, fmt.Errorf("provided resource reference is nil or empty: RoleRef") + } + namespace := ko.GetNamespace() + if arr.Namespace != nil && *arr.Namespace != "" { + namespace = *arr.Namespace + } + obj := &svcapitypes.Role{} + if err := getReferencedRole(ctx, apiReader, obj, *arr.Name, namespace); err != nil { + return hasReferences, err + } + ko.Spec.RoleName = obj.Spec.Name + return hasReferences, nil +} + +func (rm *resourceManager) resolveReferenceForPolicy( + ctx context.Context, + apiReader client.Reader, + ko *svcapitypes.RolePolicyAttachment, +) (hasReferences bool, err error) { + if ko.Spec.PolicyRef == nil || ko.Spec.PolicyRef.From == nil { + return false, nil + } + hasReferences = true + arr := ko.Spec.PolicyRef.From + if arr.Name == nil || *arr.Name == "" { + return hasReferences, fmt.Errorf("provided resource reference is nil or empty: PolicyRef") + } + namespace := ko.GetNamespace() + if arr.Namespace != nil && *arr.Namespace != "" { + namespace = *arr.Namespace + } + obj := &svcapitypes.Policy{} + if err := getReferencedPolicy(ctx, apiReader, obj, *arr.Name, namespace); err != nil { + return hasReferences, err + } + if obj.Status.ACKResourceMetadata == nil || obj.Status.ACKResourceMetadata.ARN == nil { + return hasReferences, ackerr.ResourceReferenceMissingTargetFieldFor("Policy", namespace, *arr.Name, "Status.ACKResourceMetadata.ARN") + } + policyARN := string(*obj.Status.ACKResourceMetadata.ARN) + ko.Spec.PolicyARN = &policyARN + return hasReferences, nil +} + +func getReferencedRole(ctx context.Context, apiReader client.Reader, obj *svcapitypes.Role, name string, namespace string) error { + namespacedName := types.NamespacedName{Namespace: namespace, Name: name} + if err := apiReader.Get(ctx, namespacedName, obj); err != nil { + return err + } + for _, cond := range obj.Status.Conditions { + if cond.Type == ackv1alpha1.ConditionTypeTerminal && cond.Status == corev1.ConditionTrue { + return ackerr.ResourceReferenceTerminalFor("Role", namespace, name) + } + } + refResourceSynced := false + for _, cond := range obj.Status.Conditions { + if cond.Type == ackv1alpha1.ConditionTypeResourceSynced && cond.Status == corev1.ConditionTrue { + refResourceSynced = true + } + } + if !refResourceSynced { + return ackerr.ResourceReferenceNotSyncedFor("Role", namespace, name) + } + if obj.Spec.Name == nil { + return ackerr.ResourceReferenceMissingTargetFieldFor("Role", namespace, name, "Spec.Name") + } + return nil +} + +func getReferencedPolicy(ctx context.Context, apiReader client.Reader, obj *svcapitypes.Policy, name string, namespace string) error { + namespacedName := types.NamespacedName{Namespace: namespace, Name: name} + if err := apiReader.Get(ctx, namespacedName, obj); err != nil { + return err + } + for _, cond := range obj.Status.Conditions { + if cond.Type == ackv1alpha1.ConditionTypeTerminal && cond.Status == corev1.ConditionTrue { + return ackerr.ResourceReferenceTerminalFor("Policy", namespace, name) + } + } + refResourceSynced := false + for _, cond := range obj.Status.Conditions { + if cond.Type == ackv1alpha1.ConditionTypeResourceSynced && cond.Status == corev1.ConditionTrue { + refResourceSynced = true + } + } + if !refResourceSynced { + return ackerr.ResourceReferenceNotSyncedFor("Policy", namespace, name) + } + return nil +} diff --git a/pkg/resource/role_policy_attachment/resource.go b/pkg/resource/role_policy_attachment/resource.go new file mode 100644 index 0000000..c610b89 --- /dev/null +++ b/pkg/resource/role_policy_attachment/resource.go @@ -0,0 +1,93 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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 role_policy_attachment + +import ( + "fmt" + + ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" + ackerrors "github.com/aws-controllers-k8s/runtime/pkg/errors" + acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + rtclient "sigs.k8s.io/controller-runtime/pkg/client" + + svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" +) + +var ( + _ = &ackerrors.MissingNameIdentifier +) + +type resource struct { + ko *svcapitypes.RolePolicyAttachment +} + +func (r *resource) Identifiers() acktypes.AWSResourceIdentifiers { + return &resourceIdentifiers{r.ko.Status.ACKResourceMetadata} +} + +func (r *resource) IsBeingDeleted() bool { + return !r.ko.DeletionTimestamp.IsZero() +} + +func (r *resource) RuntimeObject() rtclient.Object { + return r.ko +} + +func (r *resource) MetaObject() metav1.Object { + return r.ko.GetObjectMeta() +} + +func (r *resource) Conditions() []*ackv1alpha1.Condition { + return r.ko.Status.Conditions +} + +func (r *resource) ReplaceConditions(conditions []*ackv1alpha1.Condition) { + r.ko.Status.Conditions = conditions +} + +func (r *resource) SetObjectMeta(meta metav1.ObjectMeta) { + r.ko.ObjectMeta = meta +} + +func (r *resource) SetStatus(desired acktypes.AWSResource) { + r.ko.Status = desired.(*resource).ko.Status +} + +func (r *resource) SetIdentifiers(identifier *ackv1alpha1.AWSIdentifiers) error { + if identifier.NameOrID == "" { + return ackerrors.MissingNameIdentifier + } + r.ko.Name = identifier.NameOrID + return nil +} + +func (r *resource) PopulateResourceFromAnnotation(fields map[string]string) error { + roleName, ok := fields["roleName"] + if !ok { + return ackerrors.NewTerminalError(fmt.Errorf("required field missing: roleName")) + } + policyARN, ok := fields["policyARN"] + if !ok { + return ackerrors.NewTerminalError(fmt.Errorf("required field missing: policyARN")) + } + r.ko.Spec.RoleName = &roleName + r.ko.Spec.PolicyARN = &policyARN + return nil +} + +func (r *resource) DeepCopy() acktypes.AWSResource { + koCopy := r.ko.DeepCopy() + return &resource{koCopy} +} diff --git a/pkg/resource/role_policy_attachment/sdk.go b/pkg/resource/role_policy_attachment/sdk.go new file mode 100644 index 0000000..f974fe4 --- /dev/null +++ b/pkg/resource/role_policy_attachment/sdk.go @@ -0,0 +1,246 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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 role_policy_attachment + +import ( + "context" + "errors" + "fmt" + "reflect" + + ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" + ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" + ackrtlog "github.com/aws-controllers-k8s/runtime/pkg/runtime/log" + "github.com/aws/aws-sdk-go-v2/aws" + svcsdk "github.com/aws/aws-sdk-go-v2/service/iam" + smithy "github.com/aws/smithy-go" + corev1 "k8s.io/api/core/v1" + + svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" +) + +var ( + _ = &svcsdk.Client{} + _ = &svcapitypes.RolePolicyAttachment{} + _ = ackv1alpha1.AWSAccountID("") + _ = &reflect.Value{} + _ = fmt.Sprintf("") + _ = &aws.Config{} +) + +func (rm *resourceManager) sdkFind(ctx context.Context, r *resource) (latest *resource, err error) { + rlog := ackrtlog.FromContext(ctx) + exit := rlog.Trace("rm.sdkFind") + defer func() { exit(err) }() + if rm.requiredFieldsMissingFromReadOneInput(r) { + return nil, ackerr.NotFound + } + attached, err := rm.attachmentExists(ctx, r.ko.Spec.RoleName, r.ko.Spec.PolicyARN) + if err != nil { + return nil, err + } + if !attached { + return nil, ackerr.NotFound + } + ko := r.ko.DeepCopy() + rm.setStatusDefaults(ko) + ko.Status.Attached = aws.Bool(true) + return &resource{ko}, nil +} + +func (rm *resourceManager) requiredFieldsMissingFromReadOneInput(r *resource) bool { + return r.ko.Spec.RoleName == nil || r.ko.Spec.PolicyARN == nil +} + +func (rm *resourceManager) sdkCreate(ctx context.Context, desired *resource) (created *resource, err error) { + rlog := ackrtlog.FromContext(ctx) + exit := rlog.Trace("rm.sdkCreate") + defer func() { exit(err) }() + if desired.ko.Spec.RoleName == nil || desired.ko.Spec.PolicyARN == nil { + return nil, ackerr.NewTerminalError(fmt.Errorf("roleName and policyARN are required")) + } + input := &svcsdk.AttachRolePolicyInput{RoleName: desired.ko.Spec.RoleName, PolicyArn: desired.ko.Spec.PolicyARN} + _, err = rm.sdkapi.AttachRolePolicy(ctx, input) + rm.metrics.RecordAPICall("CREATE", "AttachRolePolicy", err) + if err != nil { + return nil, err + } + return rm.sdkFind(ctx, desired) +} + +func (rm *resourceManager) sdkUpdate( + ctx context.Context, + desired *resource, + latest *resource, + delta interface{}, +) (updated *resource, err error) { + if desired.ko.Spec.RoleName != nil && latest.ko.Spec.RoleName != nil && *desired.ko.Spec.RoleName != *latest.ko.Spec.RoleName { + return nil, ackerr.NewTerminalError(fmt.Errorf("updates to spec.roleName are not supported")) + } + if desired.ko.Spec.PolicyARN != nil && latest.ko.Spec.PolicyARN != nil && *desired.ko.Spec.PolicyARN != *latest.ko.Spec.PolicyARN { + return nil, ackerr.NewTerminalError(fmt.Errorf("updates to spec.policyARN are not supported")) + } + return rm.sdkFind(ctx, desired) +} + +func (rm *resourceManager) sdkDelete(ctx context.Context, r *resource) (deleted *resource, err error) { + rlog := ackrtlog.FromContext(ctx) + exit := rlog.Trace("rm.sdkDelete") + defer func() { exit(err) }() + if r.ko.Spec.RoleName == nil || r.ko.Spec.PolicyARN == nil { + return r, nil + } + input := &svcsdk.DetachRolePolicyInput{RoleName: r.ko.Spec.RoleName, PolicyArn: r.ko.Spec.PolicyARN} + _, err = rm.sdkapi.DetachRolePolicy(ctx, input) + rm.metrics.RecordAPICall("DELETE", "DetachRolePolicy", err) + if err != nil { + var apiErr smithy.APIError + if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NoSuchEntity" { + return r, nil + } + return nil, err + } + ko := r.ko.DeepCopy() + rm.setStatusDefaults(ko) + ko.Status.Attached = aws.Bool(false) + return &resource{ko}, nil +} + +func (rm *resourceManager) setStatusDefaults(ko *svcapitypes.RolePolicyAttachment) { + if ko.Status.ACKResourceMetadata == nil { + ko.Status.ACKResourceMetadata = &ackv1alpha1.ResourceMetadata{} + } + if ko.Status.ACKResourceMetadata.Region == nil { + ko.Status.ACKResourceMetadata.Region = &rm.awsRegion + } + if ko.Status.ACKResourceMetadata.Partition == nil { + ko.Status.ACKResourceMetadata.Partition = &rm.awsPartition + } + if ko.Status.ACKResourceMetadata.OwnerAccountID == nil { + ko.Status.ACKResourceMetadata.OwnerAccountID = &rm.awsAccountID + } + if ko.Status.Conditions == nil { + ko.Status.Conditions = []*ackv1alpha1.Condition{} + } + if ko.Status.Attached == nil { + ko.Status.Attached = aws.Bool(false) + } +} + +func (rm *resourceManager) updateConditions(r *resource, onSuccess bool, err error) (*resource, bool) { + ko := r.ko.DeepCopy() + rm.setStatusDefaults(ko) + var terminalCondition *ackv1alpha1.Condition + var recoverableCondition *ackv1alpha1.Condition + var syncCondition *ackv1alpha1.Condition + for _, condition := range ko.Status.Conditions { + if condition.Type == ackv1alpha1.ConditionTypeTerminal { + terminalCondition = condition + } + if condition.Type == ackv1alpha1.ConditionTypeRecoverable { + recoverableCondition = condition + } + if condition.Type == ackv1alpha1.ConditionTypeResourceSynced { + syncCondition = condition + } + } + var termError *ackerr.TerminalError + if rm.terminalAWSError(err) || errors.As(err, &termError) { + if terminalCondition == nil { + terminalCondition = &ackv1alpha1.Condition{Type: ackv1alpha1.ConditionTypeTerminal} + ko.Status.Conditions = append(ko.Status.Conditions, terminalCondition) + } + errorMessage := err.Error() + if awsErr, _ := ackerr.AWSError(err); awsErr != nil { + errorMessage = awsErr.Error() + } + terminalCondition.Status = corev1.ConditionTrue + terminalCondition.Message = &errorMessage + } else { + if terminalCondition != nil { + terminalCondition.Status = corev1.ConditionFalse + terminalCondition.Message = nil + } + if err != nil { + if recoverableCondition == nil { + recoverableCondition = &ackv1alpha1.Condition{Type: ackv1alpha1.ConditionTypeRecoverable} + ko.Status.Conditions = append(ko.Status.Conditions, recoverableCondition) + } + recoverableCondition.Status = corev1.ConditionTrue + errorMessage := err.Error() + if awsErr, _ := ackerr.AWSError(err); awsErr != nil { + errorMessage = awsErr.Error() + } + recoverableCondition.Message = &errorMessage + } else if recoverableCondition != nil { + recoverableCondition.Status = corev1.ConditionFalse + recoverableCondition.Message = nil + } + } + if syncCondition == nil { + syncCondition = &ackv1alpha1.Condition{Type: ackv1alpha1.ConditionTypeResourceSynced} + ko.Status.Conditions = append(ko.Status.Conditions, syncCondition) + } + if onSuccess && err == nil { + syncCondition.Status = corev1.ConditionTrue + syncCondition.Message = nil + } else { + syncCondition.Status = corev1.ConditionFalse + if err != nil { + errorMessage := err.Error() + syncCondition.Message = &errorMessage + } else { + syncCondition.Message = nil + } + } + return &resource{ko}, true +} + +func (rm *resourceManager) terminalAWSError(err error) bool { + if err == nil { + return false + } + var terminalErr smithy.APIError + if !errors.As(err, &terminalErr) { + return false + } + switch terminalErr.ErrorCode() { + case "InvalidInput", "MalformedPolicyDocument": + return true + default: + return false + } +} + +func (rm *resourceManager) attachmentExists(ctx context.Context, roleName *string, policyARN *string) (bool, error) { + input := &svcsdk.ListAttachedRolePoliciesInput{RoleName: roleName} + paginator := svcsdk.NewListAttachedRolePoliciesPaginator(rm.sdkapi, input) + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + var apiErr smithy.APIError + if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NoSuchEntity" { + return false, nil + } + return false, err + } + for _, attached := range page.AttachedPolicies { + if attached.PolicyArn != nil && policyARN != nil && *attached.PolicyArn == *policyARN { + return true, nil + } + } + } + rm.metrics.RecordAPICall("READ_MANY", "ListAttachedRolePolicies", nil) + return false, nil +} diff --git a/test/e2e/common/types.py b/test/e2e/common/types.py index db7144f..a11c418 100644 --- a/test/e2e/common/types.py +++ b/test/e2e/common/types.py @@ -3,3 +3,4 @@ POLICY_RESOURCE_PLURAL = 'policies' USER_RESOURCE_PLURAL = 'users' SERVICE_LINKED_ROLE_RESOURCE_PLURAL = 'servicelinkedroles' +ROLE_POLICY_ATTACHMENT_RESOURCE_PLURAL = 'rolepolicyattachments' diff --git a/test/e2e/resources/role_policy_attachment_referring.yaml b/test/e2e/resources/role_policy_attachment_referring.yaml new file mode 100644 index 0000000..a8dc98a --- /dev/null +++ b/test/e2e/resources/role_policy_attachment_referring.yaml @@ -0,0 +1,11 @@ +apiVersion: iam.services.k8s.aws/v1alpha1 +kind: RolePolicyAttachment +metadata: + name: $ATTACHMENT_NAME +spec: + roleRef: + from: + name: $ROLE_CR_NAME + policyRef: + from: + name: $POLICY_CR_NAME diff --git a/test/e2e/tests/test_role_policy_attachment.py b/test/e2e/tests/test_role_policy_attachment.py new file mode 100644 index 0000000..fd4d1c0 --- /dev/null +++ b/test/e2e/tests/test_role_policy_attachment.py @@ -0,0 +1,318 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You may +# not use this file except in compliance with the License. A copy of the +# License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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. + +"""Integration tests for the IAM RolePolicyAttachment resource""" + +import datetime +import time + +import pytest + +from acktest.k8s import condition +from acktest.k8s import resource as k8s +from acktest.resources import random_suffix_name +from e2e import service_marker, CRD_GROUP, CRD_VERSION, load_resource +from e2e.common.types import ( + POLICY_RESOURCE_PLURAL, + ROLE_POLICY_ATTACHMENT_RESOURCE_PLURAL, + ROLE_RESOURCE_PLURAL, +) +from e2e.replacement_values import REPLACEMENT_VALUES +from e2e import policy +from e2e import role + +DELETE_WAIT_SECONDS = 10 +CHECK_WAIT_SECONDS = 10 +WAIT_TIMEOUT_SECONDS = 300 +WAIT_INTERVAL_SECONDS = 10 + + +@pytest.fixture(scope="function") +def role_and_policy(): + role_name = random_suffix_name("attach-role", 24) + policy_name = random_suffix_name("attach-policy", 24) + + role_replacements = REPLACEMENT_VALUES.copy() + role_replacements["ROLE_NAME"] = role_name + role_replacements["ROLE_DESCRIPTION"] = "role for role policy attachment test" + role_replacements["MAX_SESSION_DURATION"] = "3600" + + role_resource_data = load_resource( + "role_simple", + additional_replacements=role_replacements, + ) + + role_ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, ROLE_RESOURCE_PLURAL, + role_name, namespace="default", + ) + k8s.create_custom_resource(role_ref, role_resource_data) + role_cr = k8s.wait_resource_consumed_by_controller(role_ref) + assert role_cr is not None + + policy_replacements = REPLACEMENT_VALUES.copy() + policy_replacements["POLICY_NAME"] = policy_name + policy_replacements["POLICY_DESCRIPTION"] = "policy for role policy attachment test" + + policy_resource_data = load_resource( + "policy_simple", + additional_replacements=policy_replacements, + ) + + policy_ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, POLICY_RESOURCE_PLURAL, + policy_name, namespace="default", + ) + k8s.create_custom_resource(policy_ref, policy_resource_data) + policy_cr = k8s.wait_resource_consumed_by_controller(policy_ref) + assert policy_cr is not None + + time.sleep(CHECK_WAIT_SECONDS) + condition.assert_synced(role_ref) + condition.assert_synced(policy_ref) + + role.wait_until_exists(role_name) + + latest_policy_cr = k8s.get_resource(policy_ref) + assert latest_policy_cr is not None + assert "status" in latest_policy_cr + assert "ackResourceMetadata" in latest_policy_cr["status"] + assert "arn" in latest_policy_cr["status"]["ackResourceMetadata"] + policy_arn = latest_policy_cr["status"]["ackResourceMetadata"]["arn"] + + policy.wait_until_exists(policy_arn) + + yield { + "role_name": role_name, + "policy_arn": policy_arn, + "role_ref": role_ref, + "policy_ref": policy_ref, + } + + if k8s.get_resource_exists(role_ref): + _, deleted = k8s.delete_custom_resource( + role_ref, + period_length=DELETE_WAIT_SECONDS, + ) + assert deleted + role.wait_until_deleted(role_name) + + if k8s.get_resource_exists(policy_ref): + _, deleted = k8s.delete_custom_resource( + policy_ref, + period_length=DELETE_WAIT_SECONDS, + ) + assert deleted + policy.wait_until_deleted(policy_arn) + + +def wait_until_policy_attached(role_name: str, policy_arn: str) -> None: + now = datetime.datetime.now() + timeout = now + datetime.timedelta(seconds=WAIT_TIMEOUT_SECONDS) + + while True: + if datetime.datetime.now() >= timeout: + pytest.fail("Timed out waiting for policy to be attached to role") + + attached_policy_arns = role.get_attached_policy_arns(role_name) + if attached_policy_arns is not None and policy_arn in attached_policy_arns: + return + + time.sleep(WAIT_INTERVAL_SECONDS) + + +def wait_until_policy_detached(role_name: str, policy_arn: str) -> None: + now = datetime.datetime.now() + timeout = now + datetime.timedelta(seconds=WAIT_TIMEOUT_SECONDS) + + while True: + if datetime.datetime.now() >= timeout: + pytest.fail("Timed out waiting for policy to be detached from role") + + attached_policy_arns = role.get_attached_policy_arns(role_name) + if attached_policy_arns is not None and policy_arn not in attached_policy_arns: + return + + time.sleep(WAIT_INTERVAL_SECONDS) + + +@service_marker +@pytest.mark.canary +class TestRolePolicyAttachment: + def test_create_delete_with_references(self, role_and_policy): + """Test attachment lifecycle using ACK resource references.""" + role_name = role_and_policy["role_name"] + policy_arn = role_and_policy["policy_arn"] + + attachment_name = random_suffix_name("attach", 24) + attachment_replacements = REPLACEMENT_VALUES.copy() + attachment_replacements["ATTACHMENT_NAME"] = attachment_name + attachment_replacements["ROLE_CR_NAME"] = role_name + attachment_replacements["POLICY_CR_NAME"] = role_and_policy["policy_ref"].name + + attachment_resource_data = load_resource( + "role_policy_attachment_referring", + additional_replacements=attachment_replacements, + ) + + attachment_ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, ROLE_POLICY_ATTACHMENT_RESOURCE_PLURAL, + attachment_name, namespace="default", + ) + k8s.create_custom_resource(attachment_ref, attachment_resource_data) + attachment_cr = k8s.wait_resource_consumed_by_controller(attachment_ref) + + assert attachment_cr is not None + condition.assert_synced(attachment_ref) + wait_until_policy_attached(role_name, policy_arn) + + _, deleted = k8s.delete_custom_resource( + attachment_ref, + period_length=DELETE_WAIT_SECONDS, + ) + assert deleted + + wait_until_policy_detached(role_name, policy_arn) + + def test_attach_multiple_policies_to_roles(self, role_and_policy): + """Test attaching multiple policies to multiple ACK-managed roles.""" + role1_name = role_and_policy["role_name"] + policy1_arn = role_and_policy["policy_arn"] + + # Create second role and policy + role2_name = random_suffix_name("attach-role2", 24) + policy2_name = random_suffix_name("attach-policy2", 24) + + role2_replacements = REPLACEMENT_VALUES.copy() + role2_replacements["ROLE_NAME"] = role2_name + role2_replacements["ROLE_DESCRIPTION"] = "second role for policy attachment test" + role2_replacements["MAX_SESSION_DURATION"] = "3600" + + role2_resource_data = load_resource( + "role_simple", + additional_replacements=role2_replacements, + ) + + role2_ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, ROLE_RESOURCE_PLURAL, + role2_name, namespace="default", + ) + k8s.create_custom_resource(role2_ref, role2_resource_data) + role2_cr = k8s.wait_resource_consumed_by_controller(role2_ref) + assert role2_cr is not None + + policy2_replacements = REPLACEMENT_VALUES.copy() + policy2_replacements["POLICY_NAME"] = policy2_name + policy2_replacements["POLICY_DESCRIPTION"] = "second policy for attachment test" + + policy2_resource_data = load_resource( + "policy_simple", + additional_replacements=policy2_replacements, + ) + + policy2_ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, POLICY_RESOURCE_PLURAL, + policy2_name, namespace="default", + ) + k8s.create_custom_resource(policy2_ref, policy2_resource_data) + policy2_cr = k8s.wait_resource_consumed_by_controller(policy2_ref) + assert policy2_cr is not None + + time.sleep(CHECK_WAIT_SECONDS) + condition.assert_synced(role2_ref) + condition.assert_synced(policy2_ref) + + role.wait_until_exists(role2_name) + + latest_policy2_cr = k8s.get_resource(policy2_ref) + assert latest_policy2_cr is not None + policy2_arn = latest_policy2_cr["status"]["ackResourceMetadata"]["arn"] + policy.wait_until_exists(policy2_arn) + + # Attachment 1: policy1 to role1 + attachment1_name = random_suffix_name("attach1", 24) + attachment1_replacements = REPLACEMENT_VALUES.copy() + attachment1_replacements["ATTACHMENT_NAME"] = attachment1_name + attachment1_replacements["ROLE_CR_NAME"] = role_and_policy["role_ref"].name + attachment1_replacements["POLICY_CR_NAME"] = role_and_policy["policy_ref"].name + + attachment1_resource_data = load_resource( + "role_policy_attachment_referring", + additional_replacements=attachment1_replacements, + ) + + attachment1_ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, ROLE_POLICY_ATTACHMENT_RESOURCE_PLURAL, + attachment1_name, namespace="default", + ) + k8s.create_custom_resource(attachment1_ref, attachment1_resource_data) + attachment1_cr = k8s.wait_resource_consumed_by_controller(attachment1_ref) + assert attachment1_cr is not None + condition.assert_synced(attachment1_ref) + wait_until_policy_attached(role_and_policy["role_name"], policy1_arn) + + # Attachment 2: policy2 to role2 + attachment2_name = random_suffix_name("attach2", 24) + attachment2_replacements = REPLACEMENT_VALUES.copy() + attachment2_replacements["ATTACHMENT_NAME"] = attachment2_name + attachment2_replacements["ROLE_CR_NAME"] = role2_ref.name + attachment2_replacements["POLICY_CR_NAME"] = policy2_ref.name + + attachment2_resource_data = load_resource( + "role_policy_attachment_referring", + additional_replacements=attachment2_replacements, + ) + + attachment2_ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, ROLE_POLICY_ATTACHMENT_RESOURCE_PLURAL, + attachment2_name, namespace="default", + ) + k8s.create_custom_resource(attachment2_ref, attachment2_resource_data) + attachment2_cr = k8s.wait_resource_consumed_by_controller(attachment2_ref) + assert attachment2_cr is not None + condition.assert_synced(attachment2_ref) + wait_until_policy_attached(role2_name, policy2_arn) + + # Verify both attachments are in synced state + condition.assert_synced(attachment1_ref) + condition.assert_synced(attachment2_ref) + + # Clean up attachment2 and role2 + _, deleted = k8s.delete_custom_resource( + attachment2_ref, + period_length=DELETE_WAIT_SECONDS, + ) + assert deleted + wait_until_policy_detached(role2_name, policy2_arn) + + _, deleted = k8s.delete_custom_resource( + role2_ref, + period_length=DELETE_WAIT_SECONDS, + ) + assert deleted + role.wait_until_deleted(role2_name) + + _, deleted = k8s.delete_custom_resource( + policy2_ref, + period_length=DELETE_WAIT_SECONDS, + ) + assert deleted + policy.wait_until_deleted(policy2_arn) + + # Clean up attachment1 + _, deleted = k8s.delete_custom_resource( + attachment1_ref, + period_length=DELETE_WAIT_SECONDS, + ) + assert deleted + wait_until_policy_detached(role_and_policy["role_name"], policy1_arn) From 97b89f0fa245de3a0a40db06c7a1e49a731171d1 Mon Sep 17 00:00:00 2001 From: Mostafa Anas Date: Fri, 19 Jun 2026 16:10:20 +0100 Subject: [PATCH 2/5] Remove RolePolicyAttachment example manifests from controller PR scope Move local usage examples to parent ack-cont/examples workspace directory to keep upstream contribution focused on generated resource, controller wiring, RBAC, and tests. --- examples/rolepolicyattachment-direct.yaml | 12 ---- examples/rolepolicyattachment-with-refs.yaml | 59 -------------------- 2 files changed, 71 deletions(-) delete mode 100644 examples/rolepolicyattachment-direct.yaml delete mode 100644 examples/rolepolicyattachment-with-refs.yaml diff --git a/examples/rolepolicyattachment-direct.yaml b/examples/rolepolicyattachment-direct.yaml deleted file mode 100644 index 47c36fe..0000000 --- a/examples/rolepolicyattachment-direct.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Example 2: RolePolicyAttachment using direct ARN/name references -# Use this when Role and Policy already exist in AWS and are not managed by ACK. - ---- -apiVersion: iam.services.k8s.aws/v1alpha1 -kind: RolePolicyAttachment -metadata: - name: attach-existing-lambda-execution -spec: - # Direct reference to existing AWS resources - roleName: lambda-execution-role - policyARN: arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole diff --git a/examples/rolepolicyattachment-with-refs.yaml b/examples/rolepolicyattachment-with-refs.yaml deleted file mode 100644 index bfe5a1f..0000000 --- a/examples/rolepolicyattachment-with-refs.yaml +++ /dev/null @@ -1,59 +0,0 @@ -# Example 1: RolePolicyAttachment using Kubernetes references -# This is the recommended pattern when you want to manage Role and Policy as ACK CRs too. - ---- -apiVersion: iam.services.k8s.aws/v1alpha1 -kind: Role -metadata: - name: my-app-role -spec: - name: my-app-role - assumeRolePolicyDocument: | - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { - "Service": "ec2.amazonaws.com" - }, - "Action": "sts:AssumeRole" - } - ] - } - ---- -apiVersion: iam.services.k8s.aws/v1alpha1 -kind: Policy -metadata: - name: s3-read-only -spec: - name: s3-read-only-policy - policyDocument: | - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket", - "s3:GetObject" - ], - "Resource": "*" - } - ] - } - ---- -apiVersion: iam.services.k8s.aws/v1alpha1 -kind: RolePolicyAttachment -metadata: - name: attach-s3-read -spec: - # Use references to other ACK resources - roleRef: - from: - name: my-app-role - policyRef: - from: - name: s3-read-only From 36bae8d3541ee8cacaffc82e46bebfd1b8352ba4 Mon Sep 17 00:00:00 2001 From: Mostafa Anas Date: Wed, 15 Jul 2026 07:07:19 +0100 Subject: [PATCH 3/5] Add additive managed-policy reconciliation mode for Role Introduce an opt-in annotation on the Role resource: iam.services.k8s.aws/policy-reconciliation-mode: additive When set, the controller attaches the managed policies listed in Spec.Policies but never detaches managed policies that were attached out-of-band (e.g. by a RolePolicyAttachment resource, or by a role shared across clusters/accounts). The default behavior (annotation absent) remains authoritative and unchanged, so existing Role manifests are fully backward compatible. - hooks.go: add isAdditivePolicyMode() + comparePolicies(); skip the detach set in syncManagedPolicies when additive. - generator.yaml: mark Spec.Policies compare is_ignored so comparison is delegated to comparePolicies() in customPreCompare (mode-aware). - delta.go: drop the generated Spec.Policies comparison accordingly. - delete path (template + sdk.go): clear the annotation on the delete copy so DeleteRole still performs a full detach (IAM requires no attached managed policies before deletion). - delta_test.go: unit tests for authoritative vs additive comparison. - e2e: test_role_additive_policies.py verifies out-of-band policies are not detached, new desired policies are still attached, and the resource stays synced (no drift loop). --- generator.yaml | 6 + pkg/resource/role/delta.go | 7 - pkg/resource/role/delta_test.go | 102 +++++++++++ pkg/resource/role/hooks.go | 61 +++++++ pkg/resource/role/sdk.go | 7 + .../role/sdk_delete_pre_build_request.go.tpl | 7 + test/e2e/tests/test_role_additive_policies.py | 172 ++++++++++++++++++ 7 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 test/e2e/tests/test_role_additive_policies.py diff --git a/generator.yaml b/generator.yaml index acb15af..27675b9 100644 --- a/generator.yaml +++ b/generator.yaml @@ -235,11 +235,17 @@ resources: # In order to support attaching zero or more policies to a role, we use # custom update code path code that uses the Attach/DetachRolePolicy API # calls to manage the set of PolicyARNs attached to this Role. + # + # Comparison is delegated to comparePolicies() in customPreCompare so that + # it can honor the additive policy-reconciliation mode annotation, where + # managed policies attached out-of-band are never treated as drift. Policies: type: "[]*string" references: resource: Policy path: Status.ACKResourceMetadata.ARN + compare: + is_ignored: true # These are policy documents that are added to the Role using the # Put/DeleteRolePolicy APIs, as compared to the Attach/DetachRolePolicy # APIs that are for non-inline managed policies. diff --git a/pkg/resource/role/delta.go b/pkg/resource/role/delta.go index d85d679..0009364 100644 --- a/pkg/resource/role/delta.go +++ b/pkg/resource/role/delta.go @@ -95,13 +95,6 @@ func newResourceDelta( if !equality.Semantic.Equalities.DeepEqual(a.ko.Spec.PermissionsBoundaryRef, b.ko.Spec.PermissionsBoundaryRef) { delta.Add("Spec.PermissionsBoundaryRef", a.ko.Spec.PermissionsBoundaryRef, b.ko.Spec.PermissionsBoundaryRef) } - if len(a.ko.Spec.Policies) != len(b.ko.Spec.Policies) { - delta.Add("Spec.Policies", a.ko.Spec.Policies, b.ko.Spec.Policies) - } else if len(a.ko.Spec.Policies) > 0 { - if !ackcompare.SliceStringPEqual(a.ko.Spec.Policies, b.ko.Spec.Policies) { - delta.Add("Spec.Policies", a.ko.Spec.Policies, b.ko.Spec.Policies) - } - } if !equality.Semantic.Equalities.DeepEqual(a.ko.Spec.PolicyRefs, b.ko.Spec.PolicyRefs) { delta.Add("Spec.PolicyRefs", a.ko.Spec.PolicyRefs, b.ko.Spec.PolicyRefs) } diff --git a/pkg/resource/role/delta_test.go b/pkg/resource/role/delta_test.go index 7bb95cb..2cbcea8 100644 --- a/pkg/resource/role/delta_test.go +++ b/pkg/resource/role/delta_test.go @@ -419,3 +419,105 @@ func TestNewResourceDelta_AssumeRolePolicyDocument(t *testing.T) { }) } } + +// roleWithManagedPolicies builds a *resource with the supplied managed policy +// ARNs and, optionally, the additive policy-reconciliation mode annotation. +func roleWithManagedPolicies(additive bool, arns ...string) *resource { + policies := make([]*string, 0, len(arns)) + for _, a := range arns { + policies = append(policies, aws.String(a)) + } + r := &resource{ + ko: &svcapitypes.Role{ + Spec: svcapitypes.RoleSpec{ + Name: aws.String("test-role"), + Policies: policies, + }, + }, + } + if additive { + r.ko.ObjectMeta.Annotations = map[string]string{ + policyReconcileModeAnnotation: "additive", + } + } + return r +} + +func TestNewResourceDelta_ManagedPolicies(t *testing.T) { + const ( + arnA = "arn:aws:iam::aws:policy/A" + arnB = "arn:aws:iam::aws:policy/B" + arnC = "arn:aws:iam::aws:policy/C" + ) + tests := []struct { + name string + desiredAdditiv bool + desired []string + latest []string + wantDiff bool + }{ + { + name: "authoritative: identical sets produce no diff", + desired: []string{arnA, arnB}, + latest: []string{arnA, arnB}, + wantDiff: false, + }, + { + name: "authoritative: extra attached policy produces a diff", + desired: []string{arnA}, + latest: []string{arnA, arnB}, + wantDiff: true, + }, + { + name: "authoritative: missing desired policy produces a diff", + desired: []string{arnA, arnB}, + latest: []string{arnA}, + wantDiff: true, + }, + { + name: "additive: identical sets produce no diff", + desiredAdditiv: true, + desired: []string{arnA, arnB}, + latest: []string{arnA, arnB}, + wantDiff: false, + }, + { + name: "additive: extra out-of-band policy produces no diff", + desiredAdditiv: true, + desired: []string{arnA}, + latest: []string{arnA, arnB, arnC}, + wantDiff: false, + }, + { + name: "additive: missing desired policy still produces a diff", + desiredAdditiv: true, + desired: []string{arnA, arnB}, + latest: []string{arnA}, + wantDiff: true, + }, + { + name: "additive: empty desired never drifts against attached policies", + desiredAdditiv: true, + desired: []string{}, + latest: []string{arnA, arnB}, + wantDiff: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + a := roleWithManagedPolicies(tc.desiredAdditiv, tc.desired...) + b := roleWithManagedPolicies(false, tc.latest...) + + delta := newResourceDelta(a, b) + + if tc.wantDiff { + assert.True(t, delta.DifferentAt("Spec.Policies"), + "expected a diff at Spec.Policies but got none") + } else { + assert.False(t, delta.DifferentAt("Spec.Policies"), + "expected no diff at Spec.Policies but got one") + } + }) + } +} diff --git a/pkg/resource/role/hooks.go b/pkg/resource/role/hooks.go index 633df78..4e6c3e1 100644 --- a/pkg/resource/role/hooks.go +++ b/pkg/resource/role/hooks.go @@ -16,6 +16,7 @@ package role import ( "context" "net/url" + "strings" ackcompare "github.com/aws-controllers-k8s/runtime/pkg/compare" ackrtlog "github.com/aws-controllers-k8s/runtime/pkg/runtime/log" @@ -93,6 +94,7 @@ func (rm *resourceManager) syncManagedPolicies( toDelete := []*string{} existingPolicies := latest.ko.Spec.Policies + additive := isAdditivePolicyMode(desired) for _, p := range desired.ko.Spec.Policies { if !ackutil.InStringPs(*p, existingPolicies) { @@ -102,6 +104,14 @@ func (rm *resourceManager) syncManagedPolicies( for _, p := range existingPolicies { if !ackutil.InStringPs(*p, desired.ko.Spec.Policies) { + if additive { + // In additive mode the controller never detaches managed + // policies that were attached out-of-band (for example by a + // RolePolicyAttachment resource or another cluster/account). + // The delete path clears the mode annotation so that role + // teardown still performs a full detach. + continue + } toDelete = append(toDelete, p) } } @@ -373,6 +383,57 @@ func customPreCompare( b *resource, ) { compareTags(delta, a, b) + comparePolicies(delta, a, b) +} + +// policyReconcileModeAnnotation, when set to "additive" on a Role, tells the +// controller to attach the managed policies listed in Spec.Policies without +// ever detaching managed policies that are attached out-of-band (for example +// by a RolePolicyAttachment resource or a role shared across clusters or +// accounts). Any other value, or the annotation being absent, preserves the +// default "authoritative" behavior where Spec.Policies is the exact desired set +// and any drift is removed. +const policyReconcileModeAnnotation = "iam.services.k8s.aws/policy-reconciliation-mode" + +// isAdditivePolicyMode returns true when the supplied Role has opted into +// additive managed-policy reconciliation via annotation. +func isAdditivePolicyMode(r *resource) bool { + if r == nil || r.ko == nil { + return false + } + v, ok := r.ko.ObjectMeta.Annotations[policyReconcileModeAnnotation] + return ok && strings.EqualFold(strings.TrimSpace(v), "additive") +} + +// comparePolicies compares the desired (a) and latest (b) managed policy sets. +// +// This replaces the generated Spec.Policies comparison (which is marked +// is_ignored in generator.yaml) so that comparison can be mode-aware. In the +// default authoritative mode it reproduces the generated exact-set comparison. +// In additive mode only missing desired policies are reported as drift; extra +// policies attached out-of-band are ignored so the controller never detaches +// them and does not requeue endlessly. +func comparePolicies( + delta *ackcompare.Delta, + a *resource, + b *resource, +) { + if isAdditivePolicyMode(a) { + for _, p := range a.ko.Spec.Policies { + if !ackutil.InStringPs(*p, b.ko.Spec.Policies) { + delta.Add("Spec.Policies", a.ko.Spec.Policies, b.ko.Spec.Policies) + return + } + } + return + } + if len(a.ko.Spec.Policies) != len(b.ko.Spec.Policies) { + delta.Add("Spec.Policies", a.ko.Spec.Policies, b.ko.Spec.Policies) + } else if len(a.ko.Spec.Policies) > 0 { + if !ackcompare.SliceStringPEqual(a.ko.Spec.Policies, b.ko.Spec.Policies) { + delta.Add("Spec.Policies", a.ko.Spec.Policies, b.ko.Spec.Policies) + } + } } // compareTags is a custom comparison function for comparing lists of Tag diff --git a/pkg/resource/role/sdk.go b/pkg/resource/role/sdk.go index f5f3cd7..fee74bf 100644 --- a/pkg/resource/role/sdk.go +++ b/pkg/resource/role/sdk.go @@ -484,6 +484,13 @@ func (rm *resourceManager) sdkDelete( }() // This deletes all associated managed and inline policies from the role roleCpy := r.ko.DeepCopy() + // Clear the additive policy-reconciliation annotation on the copy so that + // syncManagedPolicies performs a full detach of every attached managed + // policy. IAM requires a role to have no attached managed policies before + // DeleteRole succeeds, so teardown must always be authoritative. + if roleCpy.ObjectMeta.Annotations != nil { + delete(roleCpy.ObjectMeta.Annotations, policyReconcileModeAnnotation) + } roleCpy.Spec.Policies = nil if err := rm.syncManagedPolicies(ctx, &resource{ko: roleCpy}, r); err != nil { return nil, err diff --git a/templates/hooks/role/sdk_delete_pre_build_request.go.tpl b/templates/hooks/role/sdk_delete_pre_build_request.go.tpl index b86ef71..acef9fe 100644 --- a/templates/hooks/role/sdk_delete_pre_build_request.go.tpl +++ b/templates/hooks/role/sdk_delete_pre_build_request.go.tpl @@ -1,5 +1,12 @@ // This deletes all associated managed and inline policies from the role roleCpy := r.ko.DeepCopy() + // Clear the additive policy-reconciliation annotation on the copy so that + // syncManagedPolicies performs a full detach of every attached managed + // policy. IAM requires a role to have no attached managed policies before + // DeleteRole succeeds, so teardown must always be authoritative. + if roleCpy.ObjectMeta.Annotations != nil { + delete(roleCpy.ObjectMeta.Annotations, policyReconcileModeAnnotation) + } roleCpy.Spec.Policies = nil if err := rm.syncManagedPolicies(ctx, &resource{ko: roleCpy}, r); err != nil { return nil, err diff --git a/test/e2e/tests/test_role_additive_policies.py b/test/e2e/tests/test_role_additive_policies.py new file mode 100644 index 0000000..6465a2d --- /dev/null +++ b/test/e2e/tests/test_role_additive_policies.py @@ -0,0 +1,172 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You may +# not use this file except in compliance with the License. A copy of the +# License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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. + +"""Integration tests for the additive managed-policy reconciliation mode on +the IAM Role resource. + +A Role annotated with + + iam.services.k8s.aws/policy-reconciliation-mode: additive + +attaches the managed policies listed in Spec.Policies but never detaches +managed policies that were attached out-of-band (for example by a +RolePolicyAttachment resource, or by a role that is shared across clusters or +accounts). The default (annotation absent) behavior remains authoritative and +is exercised by test_role.py. +""" + +import time + +import boto3 +import pytest + +from acktest.k8s import condition +from acktest.k8s import resource as k8s +from acktest.resources import random_suffix_name +from e2e import service_marker, CRD_GROUP, CRD_VERSION, load_resource +from e2e.common.types import ROLE_RESOURCE_PLURAL +from e2e.replacement_values import REPLACEMENT_VALUES +from e2e import role + +DELETE_WAIT_AFTER_SECONDS = 10 +CHECK_STATUS_WAIT_SECONDS = 10 +MODIFY_WAIT_AFTER_SECONDS = 10 +MAX_SESS_DURATION = 3600 + +POLICY_RECONCILE_MODE_ANNOTATION = "iam.services.k8s.aws/policy-reconciliation-mode" + +# Two distinct AWS-managed policies used to exercise attach/detach behavior. +POLICY_ARN_DESIRED = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" +POLICY_ARN_OUT_OF_BAND = "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess" +POLICY_ARN_EXTRA_DESIRED = "arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess" + + +@pytest.fixture(scope="function") +def additive_role(): + role_name = random_suffix_name("additive-role", 24) + + replacements = REPLACEMENT_VALUES.copy() + replacements["ROLE_NAME"] = role_name + replacements["ROLE_DESCRIPTION"] = "role for additive policy mode test" + replacements["MAX_SESSION_DURATION"] = str(MAX_SESS_DURATION) + + resource_data = load_resource( + "role_simple", + additional_replacements=replacements, + ) + + # Opt the Role into additive managed-policy reconciliation and seed it with + # a single desired managed policy. + metadata = resource_data.setdefault("metadata", {}) + annotations = metadata.setdefault("annotations", {}) + annotations[POLICY_RECONCILE_MODE_ANNOTATION] = "additive" + resource_data.setdefault("spec", {})["policies"] = [POLICY_ARN_DESIRED] + + ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, ROLE_RESOURCE_PLURAL, + role_name, namespace="default", + ) + k8s.create_custom_resource(ref, resource_data) + cr = k8s.wait_resource_consumed_by_controller(ref) + assert cr is not None + + role.wait_until_exists(role_name) + time.sleep(CHECK_STATUS_WAIT_SECONDS) + condition.assert_synced(ref) + + yield (ref, role_name) + + _, deleted = k8s.delete_custom_resource( + ref, + period_length=DELETE_WAIT_AFTER_SECONDS, + ) + assert deleted + role.wait_until_deleted(role_name) + + +@service_marker +@pytest.mark.canary +class TestRoleAdditivePolicies: + def test_additive_mode_does_not_detach_out_of_band_policy(self, additive_role): + ref, role_name = additive_role + iam = boto3.client("iam") + + # The controller should have attached the single desired policy. + latest = role.get_attached_policy_arns(role_name) + assert latest == [POLICY_ARN_DESIRED] + + # Simulate an out-of-band attachment (e.g. a RolePolicyAttachment + # resource or a peer cluster/account) by attaching a second managed + # policy directly through the IAM API. + iam.attach_role_policy( + RoleName=role_name, + PolicyArn=POLICY_ARN_OUT_OF_BAND, + ) + + # Give the controller several reconcile loops. In authoritative mode it + # would detach POLICY_ARN_OUT_OF_BAND here; in additive mode it must + # leave it in place and must not enter a perpetual drift/requeue loop. + time.sleep(MODIFY_WAIT_AFTER_SECONDS * 3) + + latest = set(role.get_attached_policy_arns(role_name)) + assert POLICY_ARN_DESIRED in latest + assert POLICY_ARN_OUT_OF_BAND in latest, ( + "additive mode must not detach out-of-band managed policies" + ) + # The resource should still report as synced (no endless drift). + condition.assert_synced(ref) + + def test_additive_mode_still_attaches_new_desired_policies(self, additive_role): + ref, role_name = additive_role + iam = boto3.client("iam") + + # Attach an out-of-band policy that is not part of Spec.Policies. + iam.attach_role_policy( + RoleName=role_name, + PolicyArn=POLICY_ARN_OUT_OF_BAND, + ) + time.sleep(MODIFY_WAIT_AFTER_SECONDS) + + # Add a second desired policy to Spec.Policies. The controller must + # attach it while still leaving the out-of-band policy untouched. + updates = { + "spec": { + "policies": [POLICY_ARN_DESIRED, POLICY_ARN_EXTRA_DESIRED], + }, + } + k8s.patch_custom_resource(ref, updates) + time.sleep(MODIFY_WAIT_AFTER_SECONDS * 2) + condition.assert_synced(ref) + + latest = set(role.get_attached_policy_arns(role_name)) + assert POLICY_ARN_DESIRED in latest + assert POLICY_ARN_EXTRA_DESIRED in latest + assert POLICY_ARN_OUT_OF_BAND in latest + + # Removing a policy from Spec.Policies must NOT detach it in additive + # mode: the desired set only drives attaches, never detaches. + updates = { + "spec": { + "policies": [POLICY_ARN_EXTRA_DESIRED], + }, + } + k8s.patch_custom_resource(ref, updates) + time.sleep(MODIFY_WAIT_AFTER_SECONDS * 2) + condition.assert_synced(ref) + + latest = set(role.get_attached_policy_arns(role_name)) + assert POLICY_ARN_EXTRA_DESIRED in latest + assert POLICY_ARN_DESIRED in latest, ( + "additive mode must not detach a policy removed from Spec.Policies" + ) + assert POLICY_ARN_OUT_OF_BAND in latest From f9481f54801b4a5defe363bbd4d63fca94e81290 Mon Sep 17 00:00:00 2001 From: Mostafa Anas Date: Wed, 15 Jul 2026 07:20:07 +0100 Subject: [PATCH 4/5] Regenerate controller artifacts with code-generator v0.61.0 --- apis/v1alpha1/ack-generate-metadata.yaml | 8 +++--- apis/v1alpha1/generator.yaml | 6 +++++ helm/templates/deployment.yaml | 1 - helm/values.schema.json | 5 ---- helm/values.yaml | 5 ---- pkg/resource/group/references.go | 15 +++-------- pkg/resource/instance_profile/references.go | 15 +++-------- pkg/resource/role/references.go | 29 +++++---------------- pkg/resource/user/references.go | 29 +++++---------------- 9 files changed, 28 insertions(+), 85 deletions(-) diff --git a/apis/v1alpha1/ack-generate-metadata.yaml b/apis/v1alpha1/ack-generate-metadata.yaml index 642492a..acbfe8c 100755 --- a/apis/v1alpha1/ack-generate-metadata.yaml +++ b/apis/v1alpha1/ack-generate-metadata.yaml @@ -1,13 +1,13 @@ ack_generate_info: - build_date: "2026-07-07T21:50:53Z" - build_hash: 0a6b791fa10a6140d862be334a6891868ee588eb + build_date: "2026-07-15T06:14:43Z" + build_hash: e581e886276bd781409a3fb11fa665ea31de17d4 go_version: go1.26.4 version: v0.61.0 -api_directory_checksum: fcb205ac280ed1b0f107a291e5ea43d93c0991e9 +api_directory_checksum: 1160a330c5825c076085ff877addb26a2a8aa839 api_version: v1alpha1 aws_sdk_go_version: v1.32.6 generator_config_info: - file_checksum: 1339c27fce2a31f19582bb8951f169b93d4af15b + file_checksum: 84916e0a47969ba21c2bc4b5633c7210474cc8dd original_file_name: generator.yaml last_modification: reason: API generation diff --git a/apis/v1alpha1/generator.yaml b/apis/v1alpha1/generator.yaml index acb15af..27675b9 100644 --- a/apis/v1alpha1/generator.yaml +++ b/apis/v1alpha1/generator.yaml @@ -235,11 +235,17 @@ resources: # In order to support attaching zero or more policies to a role, we use # custom update code path code that uses the Attach/DetachRolePolicy API # calls to manage the set of PolicyARNs attached to this Role. + # + # Comparison is delegated to comparePolicies() in customPreCompare so that + # it can honor the additive policy-reconciliation mode annotation, where + # managed policies attached out-of-band are never treated as drift. Policies: type: "[]*string" references: resource: Policy path: Status.ACKResourceMetadata.ARN + compare: + is_ignored: true # These are policy documents that are added to the Role using the # Put/DeleteRolePolicy APIs, as compared to the Attach/DetachRolePolicy # APIs that are for non-inline managed policies. diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 67b6ec7..75c98ec 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -99,7 +99,6 @@ spec: - "$(FEATURE_GATES)" {{- end }} - --enable-carm={{ .Values.enableCARM }} - - --enable-cross-namespace={{ .Values.enableCrossNamespace }} image: {{ .Values.image.repository }}:{{ .Values.image.tag }} imagePullPolicy: {{ .Values.image.pullPolicy }} name: controller diff --git a/helm/values.schema.json b/helm/values.schema.json index 5cc01db..619cfe3 100644 --- a/helm/values.schema.json +++ b/helm/values.schema.json @@ -274,11 +274,6 @@ "description": "Parameter to enable or disable cross account resource management.", "type": "boolean", "default": true - }, - "enableCrossNamespace": { - "description": "Enable cross-namespace behavior (resource references, secret references, field exports). When false, the controller rejects any operation that crosses namespace boundaries.", - "type": "boolean", - "default": true }, "serviceAccount": { "description": "ServiceAccount settings", diff --git a/helm/values.yaml b/helm/values.yaml index cef6f57..6a335eb 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -181,11 +181,6 @@ leaderElection: # Enable Cross Account Resource Management (default = true). Set this to false to disable cross account resource management. enableCARM: true -# Enable cross-namespace behavior including resource references, secret references, -# and field exports (default = true). When false, the controller rejects any operation -# that crosses namespace boundaries. -enableCrossNamespace: true - # Configuration for feature gates. These are optional controller features that # can be individually enabled ("true") or disabled ("false") by adding key/value # pairs below. diff --git a/pkg/resource/group/references.go b/pkg/resource/group/references.go index 44fe0af..595dea8 100644 --- a/pkg/resource/group/references.go +++ b/pkg/resource/group/references.go @@ -25,7 +25,6 @@ import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" - ackrt "github.com/aws-controllers-k8s/runtime/pkg/runtime" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" @@ -96,17 +95,9 @@ func (rm *resourceManager) resolveReferenceForPolicies( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: PolicyRefs") } - namespace, err := ackrt.ResolveCrossNamespaceReference( - ctx, - rm.cfg.EnableCrossNamespace, - &ko.Status.Conditions, - ackrt.CrossNamespaceRefKindResource, - ko.ObjectMeta.GetNamespace(), - arr.Namespace, - *arr.Name, - ) - if err != nil { - return hasReferences, err + namespace := ko.ObjectMeta.GetNamespace() + if arr.Namespace != nil && *arr.Namespace != "" { + namespace = *arr.Namespace } obj := &svcapitypes.Policy{} if err := getReferencedResourceState_Policy(ctx, apiReader, obj, *arr.Name, namespace); err != nil { diff --git a/pkg/resource/instance_profile/references.go b/pkg/resource/instance_profile/references.go index fc04f58..8ab8a11 100644 --- a/pkg/resource/instance_profile/references.go +++ b/pkg/resource/instance_profile/references.go @@ -25,7 +25,6 @@ import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" - ackrt "github.com/aws-controllers-k8s/runtime/pkg/runtime" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" @@ -95,17 +94,9 @@ func (rm *resourceManager) resolveReferenceForRole( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: RoleRef") } - namespace, err := ackrt.ResolveCrossNamespaceReference( - ctx, - rm.cfg.EnableCrossNamespace, - &ko.Status.Conditions, - ackrt.CrossNamespaceRefKindResource, - ko.ObjectMeta.GetNamespace(), - arr.Namespace, - *arr.Name, - ) - if err != nil { - return hasReferences, err + namespace := ko.ObjectMeta.GetNamespace() + if arr.Namespace != nil && *arr.Namespace != "" { + namespace = *arr.Namespace } obj := &svcapitypes.Role{} if err := getReferencedResourceState_Role(ctx, apiReader, obj, *arr.Name, namespace); err != nil { diff --git a/pkg/resource/role/references.go b/pkg/resource/role/references.go index 5cd4463..94c4f6f 100644 --- a/pkg/resource/role/references.go +++ b/pkg/resource/role/references.go @@ -25,7 +25,6 @@ import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" - ackrt "github.com/aws-controllers-k8s/runtime/pkg/runtime" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" @@ -109,17 +108,9 @@ func (rm *resourceManager) resolveReferenceForPermissionsBoundary( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: PermissionsBoundaryRef") } - namespace, err := ackrt.ResolveCrossNamespaceReference( - ctx, - rm.cfg.EnableCrossNamespace, - &ko.Status.Conditions, - ackrt.CrossNamespaceRefKindResource, - ko.ObjectMeta.GetNamespace(), - arr.Namespace, - *arr.Name, - ) - if err != nil { - return hasReferences, err + namespace := ko.ObjectMeta.GetNamespace() + if arr.Namespace != nil && *arr.Namespace != "" { + namespace = *arr.Namespace } obj := &svcapitypes.Policy{} if err := getReferencedResourceState_Policy(ctx, apiReader, obj, *arr.Name, namespace); err != nil { @@ -201,17 +192,9 @@ func (rm *resourceManager) resolveReferenceForPolicies( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: PolicyRefs") } - namespace, err := ackrt.ResolveCrossNamespaceReference( - ctx, - rm.cfg.EnableCrossNamespace, - &ko.Status.Conditions, - ackrt.CrossNamespaceRefKindResource, - ko.ObjectMeta.GetNamespace(), - arr.Namespace, - *arr.Name, - ) - if err != nil { - return hasReferences, err + namespace := ko.ObjectMeta.GetNamespace() + if arr.Namespace != nil && *arr.Namespace != "" { + namespace = *arr.Namespace } obj := &svcapitypes.Policy{} if err := getReferencedResourceState_Policy(ctx, apiReader, obj, *arr.Name, namespace); err != nil { diff --git a/pkg/resource/user/references.go b/pkg/resource/user/references.go index a7f7659..b07e706 100644 --- a/pkg/resource/user/references.go +++ b/pkg/resource/user/references.go @@ -25,7 +25,6 @@ import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" - ackrt "github.com/aws-controllers-k8s/runtime/pkg/runtime" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" @@ -109,17 +108,9 @@ func (rm *resourceManager) resolveReferenceForPermissionsBoundary( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: PermissionsBoundaryRef") } - namespace, err := ackrt.ResolveCrossNamespaceReference( - ctx, - rm.cfg.EnableCrossNamespace, - &ko.Status.Conditions, - ackrt.CrossNamespaceRefKindResource, - ko.ObjectMeta.GetNamespace(), - arr.Namespace, - *arr.Name, - ) - if err != nil { - return hasReferences, err + namespace := ko.ObjectMeta.GetNamespace() + if arr.Namespace != nil && *arr.Namespace != "" { + namespace = *arr.Namespace } obj := &svcapitypes.Policy{} if err := getReferencedResourceState_Policy(ctx, apiReader, obj, *arr.Name, namespace); err != nil { @@ -201,17 +192,9 @@ func (rm *resourceManager) resolveReferenceForPolicies( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: PolicyRefs") } - namespace, err := ackrt.ResolveCrossNamespaceReference( - ctx, - rm.cfg.EnableCrossNamespace, - &ko.Status.Conditions, - ackrt.CrossNamespaceRefKindResource, - ko.ObjectMeta.GetNamespace(), - arr.Namespace, - *arr.Name, - ) - if err != nil { - return hasReferences, err + namespace := ko.ObjectMeta.GetNamespace() + if arr.Namespace != nil && *arr.Namespace != "" { + namespace = *arr.Namespace } obj := &svcapitypes.Policy{} if err := getReferencedResourceState_Policy(ctx, apiReader, obj, *arr.Name, namespace); err != nil { From 78eb350ec61caa74b08e8242380b0545164b5576 Mon Sep 17 00:00:00 2001 From: Mostafa Anas Date: Wed, 15 Jul 2026 07:45:23 +0100 Subject: [PATCH 5/5] Regenerate artifacts with upstream generator hash parity (0a6b791) --- apis/v1alpha1/ack-generate-metadata.yaml | 4 +-- helm/templates/deployment.yaml | 1 + helm/values.schema.json | 5 ++++ helm/values.yaml | 5 ++++ pkg/resource/group/references.go | 15 ++++++++--- pkg/resource/instance_profile/references.go | 15 ++++++++--- pkg/resource/role/references.go | 29 ++++++++++++++++----- pkg/resource/user/references.go | 29 ++++++++++++++++----- 8 files changed, 83 insertions(+), 20 deletions(-) diff --git a/apis/v1alpha1/ack-generate-metadata.yaml b/apis/v1alpha1/ack-generate-metadata.yaml index acbfe8c..b043554 100755 --- a/apis/v1alpha1/ack-generate-metadata.yaml +++ b/apis/v1alpha1/ack-generate-metadata.yaml @@ -1,6 +1,6 @@ ack_generate_info: - build_date: "2026-07-15T06:14:43Z" - build_hash: e581e886276bd781409a3fb11fa665ea31de17d4 + build_date: "2026-07-15T06:43:09Z" + build_hash: 0a6b791fa10a6140d862be334a6891868ee588eb go_version: go1.26.4 version: v0.61.0 api_directory_checksum: 1160a330c5825c076085ff877addb26a2a8aa839 diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 75c98ec..67b6ec7 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -99,6 +99,7 @@ spec: - "$(FEATURE_GATES)" {{- end }} - --enable-carm={{ .Values.enableCARM }} + - --enable-cross-namespace={{ .Values.enableCrossNamespace }} image: {{ .Values.image.repository }}:{{ .Values.image.tag }} imagePullPolicy: {{ .Values.image.pullPolicy }} name: controller diff --git a/helm/values.schema.json b/helm/values.schema.json index 619cfe3..5cc01db 100644 --- a/helm/values.schema.json +++ b/helm/values.schema.json @@ -274,6 +274,11 @@ "description": "Parameter to enable or disable cross account resource management.", "type": "boolean", "default": true + }, + "enableCrossNamespace": { + "description": "Enable cross-namespace behavior (resource references, secret references, field exports). When false, the controller rejects any operation that crosses namespace boundaries.", + "type": "boolean", + "default": true }, "serviceAccount": { "description": "ServiceAccount settings", diff --git a/helm/values.yaml b/helm/values.yaml index 6a335eb..cef6f57 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -181,6 +181,11 @@ leaderElection: # Enable Cross Account Resource Management (default = true). Set this to false to disable cross account resource management. enableCARM: true +# Enable cross-namespace behavior including resource references, secret references, +# and field exports (default = true). When false, the controller rejects any operation +# that crosses namespace boundaries. +enableCrossNamespace: true + # Configuration for feature gates. These are optional controller features that # can be individually enabled ("true") or disabled ("false") by adding key/value # pairs below. diff --git a/pkg/resource/group/references.go b/pkg/resource/group/references.go index 595dea8..44fe0af 100644 --- a/pkg/resource/group/references.go +++ b/pkg/resource/group/references.go @@ -25,6 +25,7 @@ import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" + ackrt "github.com/aws-controllers-k8s/runtime/pkg/runtime" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" @@ -95,9 +96,17 @@ func (rm *resourceManager) resolveReferenceForPolicies( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: PolicyRefs") } - namespace := ko.ObjectMeta.GetNamespace() - if arr.Namespace != nil && *arr.Namespace != "" { - namespace = *arr.Namespace + namespace, err := ackrt.ResolveCrossNamespaceReference( + ctx, + rm.cfg.EnableCrossNamespace, + &ko.Status.Conditions, + ackrt.CrossNamespaceRefKindResource, + ko.ObjectMeta.GetNamespace(), + arr.Namespace, + *arr.Name, + ) + if err != nil { + return hasReferences, err } obj := &svcapitypes.Policy{} if err := getReferencedResourceState_Policy(ctx, apiReader, obj, *arr.Name, namespace); err != nil { diff --git a/pkg/resource/instance_profile/references.go b/pkg/resource/instance_profile/references.go index 8ab8a11..fc04f58 100644 --- a/pkg/resource/instance_profile/references.go +++ b/pkg/resource/instance_profile/references.go @@ -25,6 +25,7 @@ import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" + ackrt "github.com/aws-controllers-k8s/runtime/pkg/runtime" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" @@ -94,9 +95,17 @@ func (rm *resourceManager) resolveReferenceForRole( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: RoleRef") } - namespace := ko.ObjectMeta.GetNamespace() - if arr.Namespace != nil && *arr.Namespace != "" { - namespace = *arr.Namespace + namespace, err := ackrt.ResolveCrossNamespaceReference( + ctx, + rm.cfg.EnableCrossNamespace, + &ko.Status.Conditions, + ackrt.CrossNamespaceRefKindResource, + ko.ObjectMeta.GetNamespace(), + arr.Namespace, + *arr.Name, + ) + if err != nil { + return hasReferences, err } obj := &svcapitypes.Role{} if err := getReferencedResourceState_Role(ctx, apiReader, obj, *arr.Name, namespace); err != nil { diff --git a/pkg/resource/role/references.go b/pkg/resource/role/references.go index 94c4f6f..5cd4463 100644 --- a/pkg/resource/role/references.go +++ b/pkg/resource/role/references.go @@ -25,6 +25,7 @@ import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" + ackrt "github.com/aws-controllers-k8s/runtime/pkg/runtime" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" @@ -108,9 +109,17 @@ func (rm *resourceManager) resolveReferenceForPermissionsBoundary( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: PermissionsBoundaryRef") } - namespace := ko.ObjectMeta.GetNamespace() - if arr.Namespace != nil && *arr.Namespace != "" { - namespace = *arr.Namespace + namespace, err := ackrt.ResolveCrossNamespaceReference( + ctx, + rm.cfg.EnableCrossNamespace, + &ko.Status.Conditions, + ackrt.CrossNamespaceRefKindResource, + ko.ObjectMeta.GetNamespace(), + arr.Namespace, + *arr.Name, + ) + if err != nil { + return hasReferences, err } obj := &svcapitypes.Policy{} if err := getReferencedResourceState_Policy(ctx, apiReader, obj, *arr.Name, namespace); err != nil { @@ -192,9 +201,17 @@ func (rm *resourceManager) resolveReferenceForPolicies( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: PolicyRefs") } - namespace := ko.ObjectMeta.GetNamespace() - if arr.Namespace != nil && *arr.Namespace != "" { - namespace = *arr.Namespace + namespace, err := ackrt.ResolveCrossNamespaceReference( + ctx, + rm.cfg.EnableCrossNamespace, + &ko.Status.Conditions, + ackrt.CrossNamespaceRefKindResource, + ko.ObjectMeta.GetNamespace(), + arr.Namespace, + *arr.Name, + ) + if err != nil { + return hasReferences, err } obj := &svcapitypes.Policy{} if err := getReferencedResourceState_Policy(ctx, apiReader, obj, *arr.Name, namespace); err != nil { diff --git a/pkg/resource/user/references.go b/pkg/resource/user/references.go index b07e706..a7f7659 100644 --- a/pkg/resource/user/references.go +++ b/pkg/resource/user/references.go @@ -25,6 +25,7 @@ import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" + ackrt "github.com/aws-controllers-k8s/runtime/pkg/runtime" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" svcapitypes "github.com/aws-controllers-k8s/iam-controller/apis/v1alpha1" @@ -108,9 +109,17 @@ func (rm *resourceManager) resolveReferenceForPermissionsBoundary( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: PermissionsBoundaryRef") } - namespace := ko.ObjectMeta.GetNamespace() - if arr.Namespace != nil && *arr.Namespace != "" { - namespace = *arr.Namespace + namespace, err := ackrt.ResolveCrossNamespaceReference( + ctx, + rm.cfg.EnableCrossNamespace, + &ko.Status.Conditions, + ackrt.CrossNamespaceRefKindResource, + ko.ObjectMeta.GetNamespace(), + arr.Namespace, + *arr.Name, + ) + if err != nil { + return hasReferences, err } obj := &svcapitypes.Policy{} if err := getReferencedResourceState_Policy(ctx, apiReader, obj, *arr.Name, namespace); err != nil { @@ -192,9 +201,17 @@ func (rm *resourceManager) resolveReferenceForPolicies( if arr.Name == nil || *arr.Name == "" { return hasReferences, fmt.Errorf("provided resource reference is nil or empty: PolicyRefs") } - namespace := ko.ObjectMeta.GetNamespace() - if arr.Namespace != nil && *arr.Namespace != "" { - namespace = *arr.Namespace + namespace, err := ackrt.ResolveCrossNamespaceReference( + ctx, + rm.cfg.EnableCrossNamespace, + &ko.Status.Conditions, + ackrt.CrossNamespaceRefKindResource, + ko.ObjectMeta.GetNamespace(), + arr.Namespace, + *arr.Name, + ) + if err != nil { + return hasReferences, err } obj := &svcapitypes.Policy{} if err := getReferencedResourceState_Policy(ctx, apiReader, obj, *arr.Name, namespace); err != nil {