Skip to content

Commit b782a43

Browse files
authored
Clean up PolicyBindings when user invitations are deleted (#543)
## Summary - Wires the existing finalizer into the UserInvitation controller so invitation-related PolicyBindings (`getinvitation` and `acceptinvitation`) are deleted before an invitation is removed - Removes a redundant manual PolicyBinding deletion in the acceptance path since the finalizer now handles cleanup of both bindings - Adds unit tests verifying finalizer registration and PolicyBinding garbage collection - Adds an end-to-end Chainsaw test for the full invitation lifecycle ## Context When a UserInvitation was deleted (accepted, expired, or revoked), the associated PolicyBindings in `milo-system` were left behind because the controller's finalizer was registered but never invoked during reconciliation. This produced 49 orphaned PolicyBindings in production stuck in `ResourceSelectorValidationFailed` state. Closes #535 ## Test plan - [x] Unit tests verify finalizer is added on first reconcile - [x] Unit tests verify both PolicyBindings are deleted on UserInvitation deletion - [x] Existing unit tests updated to account for finalizer registration cycle - [x] Chainsaw e2e test validates full lifecycle: create invitation → verify PolicyBindings → delete invitation → verify cleanup - [x] e2e test passes in CI > [!NOTE] > The CI workflow reports a failure due to pre-existing e2e test issues on `main` (`note-multicluster-subject`, `clusternote-multicluster-subject`, `crm-note-contact-lifecycle`). These are unrelated to this PR and are being resolved in #549. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 8eee3af + 8ae29a2 commit b782a43

6 files changed

Lines changed: 428 additions & 23 deletions

File tree

internal/controllers/iam/userinvitation_controller.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,29 @@ func (r *UserInvitationController) Reconcile(ctx context.Context, req ctrl.Reque
115115
return ctrl.Result{}, fmt.Errorf("failed to get UserInvitation: %w", err)
116116
}
117117

118+
// Run finalizers. This adds the finalizer string on first reconcile and
119+
// executes userInvitationFinalizer.Finalize on deletion to clean up
120+
// invitation-related PolicyBindings before the object is removed.
121+
finalizeResult, err := r.finalizer.Finalize(ctx, ui)
122+
if err != nil {
123+
log.Error(err, "Failed to run finalizers for UserInvitation")
124+
return ctrl.Result{}, fmt.Errorf("failed to run finalizers for UserInvitation: %w", err)
125+
}
126+
127+
if finalizeResult.Updated {
128+
log.Info("Finalizer updated UserInvitation, persisting to API server")
129+
if updateErr := r.Client.Update(ctx, ui); updateErr != nil {
130+
log.Error(updateErr, "Failed to update UserInvitation after finalizer update")
131+
return ctrl.Result{}, fmt.Errorf("failed to update UserInvitation after finalizer update: %w", updateErr)
132+
}
133+
return ctrl.Result{}, nil
134+
}
135+
136+
if ui.GetDeletionTimestamp() != nil {
137+
log.Info("UserInvitation is marked for deletion, stopping reconciliation")
138+
return ctrl.Result{}, nil
139+
}
140+
118141
log.Info("reconciling UserInvitation", "name", ui.Name, "email", ui.Spec.Email)
119142

120143
// Update the UserInvitation status with the invitee user information
@@ -201,15 +224,6 @@ func (r *UserInvitationController) Reconcile(ctx context.Context, req ctrl.Reque
201224

202225
// Grant roles to the invitee user for the organization if the invitation is accepted
203226
if isUserInvitationAccepted(ui) {
204-
log.Info("Deleting PolicyBindings for accepting the invitation, as the invitation has been accepted", "userInvitation", ui.GetName())
205-
if err := deletePolicyBinding(ctx, r.Client, &iamv1alpha1.RoleReference{
206-
Name: r.AcceptInvitationRoleName,
207-
Namespace: r.SystemNamespace,
208-
}, *ui); err != nil {
209-
log.Error(err, "Failed to delete PolicyBinding for accepting the invitation")
210-
return ctrl.Result{}, fmt.Errorf("failed to delete PolicyBinding for accepting the invitation: %w", err)
211-
}
212-
213227
log.Info("Creating OrganizationMembership with roles for the invitee user, as the invitation is accepted", "user", user.Name, "roles", ui.Spec.Roles)
214228

215229
// Create the OrganizationMembership with roles

internal/controllers/iam/userinvitation_controller_test.go

Lines changed: 194 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
ctrl "sigs.k8s.io/controller-runtime"
1919
"sigs.k8s.io/controller-runtime/pkg/client"
2020
"sigs.k8s.io/controller-runtime/pkg/client/fake"
21+
ctrlfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer"
2122
)
2223

2324
// getTestScheme returns a runtime.Scheme with all Milo APIs registered.
@@ -29,6 +30,29 @@ func getTestScheme() *runtime.Scheme {
2930
return scheme
3031
}
3132

33+
// initFinalizer wires up the userInvitationFinalizer on a UserInvitationController so
34+
// that unit tests exercise the full finalizer lifecycle without a live manager.
35+
func initFinalizer(t *testing.T, uic *UserInvitationController) {
36+
t.Helper()
37+
uic.finalizer = ctrlfinalizer.NewFinalizers()
38+
if err := uic.finalizer.Register(userInvitationFinalizerKey, &userInvitationFinalizer{
39+
client: uic.Client,
40+
uiRelatedRoles: uic.uiRelatedRoles,
41+
}); err != nil {
42+
t.Fatalf("failed to register userInvitation finalizer: %v", err)
43+
}
44+
}
45+
46+
// containsFinalizer returns true when the UserInvitation carries the standard finalizer string.
47+
func containsFinalizer(ui *iamv1alpha1.UserInvitation) bool {
48+
for _, f := range ui.Finalizers {
49+
if f == userInvitationFinalizerKey {
50+
return true
51+
}
52+
}
53+
return false
54+
}
55+
3256
// TestUserInvitationController_createPolicyBinding verifies that createPolicyBinding creates a PolicyBinding CR.
3357
func TestUserInvitationController_createPolicyBinding(t *testing.T) {
3458
ctx := context.TODO()
@@ -659,10 +683,21 @@ func TestUserInvitationController_Reconcile_StateTransitionCreatesBindings(t *te
659683
SystemNamespace: "milo-system",
660684
uiRelatedRoles: []iamv1alpha1.RoleReference{invitationRoleRef},
661685
}
686+
initFinalizer(t, uic)
687+
688+
// First reconcile registers the finalizer and returns early.
689+
if _, err := uic.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}}); err != nil {
690+
t.Fatalf("first reconcile (finalizer registration) error: %v", err)
691+
}
692+
registered := &iamv1alpha1.UserInvitation{}
693+
_ = c.Get(ctx, types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}, registered)
694+
if !containsFinalizer(registered) {
695+
t.Fatalf("expected finalizer to be registered after first reconcile")
696+
}
662697

663-
// First reconcile (Pending)
698+
// Second reconcile (Pending) — business logic now runs.
664699
if _, err := uic.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}}); err != nil {
665-
t.Fatalf("first reconcile error: %v", err)
700+
t.Fatalf("second reconcile error: %v", err)
666701
}
667702

668703
// Verify invitation-related PolicyBinding exists
@@ -671,11 +706,11 @@ func TestUserInvitationController_Reconcile_StateTransitionCreatesBindings(t *te
671706
t.Fatalf("expected invitation PolicyBinding created: %v", err)
672707
}
673708

674-
// Check Pending condition true, Ready false
709+
// Check Pending condition true, Ready false after the business-logic reconcile.
675710
afterFirst := &iamv1alpha1.UserInvitation{}
676711
_ = c.Get(ctx, types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}, afterFirst)
677712
if !meta.IsStatusConditionTrue(afterFirst.Status.Conditions, string(iamv1alpha1.UserInvitationPendingCondition)) {
678-
t.Fatalf("Pending condition should be true after first reconcile")
713+
t.Fatalf("Pending condition should be true after pending reconcile")
679714
}
680715
if meta.IsStatusConditionTrue(afterFirst.Status.Conditions, string(iamv1alpha1.UserInvitationReadyCondition)) {
681716
t.Fatalf("Ready condition should not be true before acceptance")
@@ -713,9 +748,9 @@ func TestUserInvitationController_Reconcile_StateTransitionCreatesBindings(t *te
713748
t.Fatalf("failed to update UI state: %v", err)
714749
}
715750

716-
// Second reconcile after state change
751+
// Third reconcile after state change (first two were finalizer registration + pending business logic).
717752
if _, err := uic.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}}); err != nil {
718-
t.Fatalf("second reconcile error: %v", err)
753+
t.Fatalf("third reconcile error: %v", err)
719754
}
720755

721756
// Verify OrganizationMembership created with roles
@@ -732,7 +767,13 @@ func TestUserInvitationController_Reconcile_StateTransitionCreatesBindings(t *te
732767
t.Fatalf("unexpected role on OrganizationMembership: %+v", om.Spec.Roles[0])
733768
}
734769

735-
// The UserInvitation should now be deleted
770+
// The acceptance path calls Delete which sets DeletionTimestamp (object has finalizer).
771+
// A fourth reconcile triggers the finalizer, which strips the finalizer and lets the object be removed.
772+
if _, err := uic.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}}); err != nil {
773+
t.Fatalf("fourth reconcile (finalizer cleanup) error: %v", err)
774+
}
775+
776+
// The UserInvitation should now be fully removed.
736777
if err := c.Get(ctx, types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}, &iamv1alpha1.UserInvitation{}); err == nil {
737778
t.Fatalf("UserInvitation should be deleted after acceptance")
738779
} else if !apierr.IsNotFound(err) {
@@ -786,10 +827,16 @@ func TestUserInvitationController_Reconcile_UserCreatedLater(t *testing.T) {
786827
c := builder.Build()
787828

788829
uic := &UserInvitationController{Client: c, SystemNamespace: "milo-system", uiRelatedRoles: []iamv1alpha1.RoleReference{invitationRoleRef}}
830+
initFinalizer(t, uic)
789831

790-
// First reconcile: no User yet
832+
// First reconcile registers the finalizer and returns early.
791833
if _, err := uic.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}}); err != nil {
792-
t.Fatalf("first reconcile error: %v", err)
834+
t.Fatalf("first reconcile (finalizer registration) error: %v", err)
835+
}
836+
837+
// Second reconcile: no User yet — business logic runs but no PolicyBinding created.
838+
if _, err := uic.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}}); err != nil {
839+
t.Fatalf("second reconcile error: %v", err)
793840
}
794841

795842
// Expect no PolicyBindings created
@@ -804,9 +851,9 @@ func TestUserInvitationController_Reconcile_UserCreatedLater(t *testing.T) {
804851
t.Fatalf("failed to create user: %v", err)
805852
}
806853

807-
// Second reconcile: should create invitation PB and Pending condition
854+
// Third reconcile: should create invitation PB and Pending condition
808855
if _, err := uic.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}}); err != nil {
809-
t.Fatalf("second reconcile error: %v", err)
856+
t.Fatalf("third reconcile error: %v", err)
810857
}
811858

812859
// Verify InviteeUser now populated after user appears (before acceptance)
@@ -830,9 +877,9 @@ func TestUserInvitationController_Reconcile_UserCreatedLater(t *testing.T) {
830877
t.Fatalf("update UI state: %v", err)
831878
}
832879

833-
// Third reconcile
880+
// Fourth reconcile: accepted state — creates OrganizationMembership and deletes the UserInvitation.
834881
if _, err := uic.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}}); err != nil {
835-
t.Fatalf("third reconcile error: %v", err)
882+
t.Fatalf("fourth reconcile error: %v", err)
836883
}
837884

838885
// Invitation should be deleted after acceptance; verified later
@@ -852,7 +899,13 @@ func TestUserInvitationController_Reconcile_UserCreatedLater(t *testing.T) {
852899
}
853900
}
854901

855-
// UserInvitation should be deleted
902+
// The acceptance path calls Delete which sets DeletionTimestamp (object has finalizer).
903+
// A fifth reconcile triggers the finalizer, which strips the finalizer and lets the object be removed.
904+
if _, err := uic.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}}); err != nil {
905+
t.Fatalf("fifth reconcile (finalizer cleanup) error: %v", err)
906+
}
907+
908+
// UserInvitation should now be fully removed.
856909
if err := c.Get(ctx, types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}, &iamv1alpha1.UserInvitation{}); err == nil {
857910
t.Fatalf("UserInvitation should be deleted after acceptance")
858911
} else if !apierr.IsNotFound(err) {
@@ -1060,3 +1113,130 @@ func TestUserInvitationController_grantAccessApproval(t *testing.T) {
10601113
})
10611114
}
10621115
}
1116+
1117+
// TestUserInvitationController_Reconcile_FinalizerRegisteredOnFirstReconcile verifies that the first
1118+
// reconcile adds the finalizer string to the UserInvitation and returns without running business logic.
1119+
func TestUserInvitationController_Reconcile_FinalizerRegisteredOnFirstReconcile(t *testing.T) {
1120+
ctx := context.TODO()
1121+
scheme := getTestScheme()
1122+
1123+
ui := &iamv1alpha1.UserInvitation{
1124+
ObjectMeta: metav1.ObjectMeta{Name: "inv", Namespace: "default", UID: types.UID("ui-uid")},
1125+
Spec: iamv1alpha1.UserInvitationSpec{
1126+
Email: "test@example.com",
1127+
OrganizationRef: resourcemanagerv1alpha1.OrganizationReference{Name: "org"},
1128+
State: iamv1alpha1.UserInvitationStatePending,
1129+
},
1130+
}
1131+
1132+
c := fake.NewClientBuilder().WithScheme(scheme).
1133+
WithStatusSubresource(&iamv1alpha1.UserInvitation{}).
1134+
WithObjects(ui.DeepCopy()).
1135+
Build()
1136+
1137+
uic := &UserInvitationController{
1138+
Client: c,
1139+
SystemNamespace: "milo-system",
1140+
}
1141+
initFinalizer(t, uic)
1142+
1143+
// Before first reconcile: no finalizer present.
1144+
before := &iamv1alpha1.UserInvitation{}
1145+
_ = c.Get(ctx, types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}, before)
1146+
if containsFinalizer(before) {
1147+
t.Fatalf("expected no finalizer before first reconcile")
1148+
}
1149+
1150+
// First reconcile should register the finalizer and return early.
1151+
result, err := uic.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}})
1152+
if err != nil {
1153+
t.Fatalf("first reconcile returned error: %v", err)
1154+
}
1155+
if result.Requeue || result.RequeueAfter != 0 {
1156+
t.Fatalf("expected empty result after finalizer registration, got %+v", result)
1157+
}
1158+
1159+
// Finalizer should now be present.
1160+
after := &iamv1alpha1.UserInvitation{}
1161+
if err := c.Get(ctx, types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}, after); err != nil {
1162+
t.Fatalf("failed to fetch UserInvitation: %v", err)
1163+
}
1164+
if !containsFinalizer(after) {
1165+
t.Fatalf("expected finalizer %q to be set after first reconcile, got finalizers: %v", userInvitationFinalizerKey, after.Finalizers)
1166+
}
1167+
1168+
// No status conditions should have been set (business logic did not run).
1169+
if len(after.Status.Conditions) != 0 {
1170+
t.Fatalf("expected no status conditions after finalizer-only reconcile, got %+v", after.Status.Conditions)
1171+
}
1172+
}
1173+
1174+
// TestUserInvitationController_Reconcile_PolicyBindingsGCOnDeletion verifies that when a UserInvitation is
1175+
// deleted the finalizer removes any invitation-related PolicyBindings before allowing the object to be removed.
1176+
func TestUserInvitationController_Reconcile_PolicyBindingsGCOnDeletion(t *testing.T) {
1177+
ctx := context.TODO()
1178+
scheme := getTestScheme()
1179+
1180+
invitationRoleRef := iamv1alpha1.RoleReference{Name: "get-invitation-role", Namespace: "milo-system"}
1181+
acceptRoleRef := iamv1alpha1.RoleReference{Name: "accept-invitation-role", Namespace: "milo-system"}
1182+
1183+
ui := &iamv1alpha1.UserInvitation{
1184+
ObjectMeta: metav1.ObjectMeta{
1185+
Name: "inv",
1186+
Namespace: "default",
1187+
UID: types.UID("ui-uid"),
1188+
Finalizers: []string{userInvitationFinalizerKey},
1189+
},
1190+
Spec: iamv1alpha1.UserInvitationSpec{
1191+
Email: "test@example.com",
1192+
OrganizationRef: resourcemanagerv1alpha1.OrganizationReference{Name: "org"},
1193+
State: iamv1alpha1.UserInvitationStatePending,
1194+
},
1195+
}
1196+
1197+
// Pre-create the PolicyBindings that should be GC-ed by the finalizer.
1198+
pbGet := &iamv1alpha1.PolicyBinding{ObjectMeta: metav1.ObjectMeta{
1199+
Name: getDeterministicRoleName(&invitationRoleRef, *ui),
1200+
Namespace: invitationRoleRef.Namespace,
1201+
}}
1202+
pbAccept := &iamv1alpha1.PolicyBinding{ObjectMeta: metav1.ObjectMeta{
1203+
Name: getDeterministicRoleName(&acceptRoleRef, *ui),
1204+
Namespace: acceptRoleRef.Namespace,
1205+
}}
1206+
1207+
c := fake.NewClientBuilder().WithScheme(scheme).
1208+
WithStatusSubresource(&iamv1alpha1.UserInvitation{}).
1209+
WithObjects(ui.DeepCopy(), pbGet, pbAccept).
1210+
Build()
1211+
1212+
uic := &UserInvitationController{
1213+
Client: c,
1214+
SystemNamespace: "milo-system",
1215+
uiRelatedRoles: []iamv1alpha1.RoleReference{invitationRoleRef, acceptRoleRef},
1216+
}
1217+
initFinalizer(t, uic)
1218+
1219+
// Mark the UserInvitation for deletion by setting DeletionTimestamp.
1220+
toDelete := &iamv1alpha1.UserInvitation{}
1221+
if err := c.Get(ctx, types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}, toDelete); err != nil {
1222+
t.Fatalf("failed to get UserInvitation: %v", err)
1223+
}
1224+
if err := c.Delete(ctx, toDelete); err != nil {
1225+
t.Fatalf("failed to delete UserInvitation: %v", err)
1226+
}
1227+
1228+
// Reconcile on the deletion event — the finalizer should run and clean up PolicyBindings.
1229+
if _, err := uic.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: ui.Name, Namespace: ui.Namespace}}); err != nil {
1230+
t.Fatalf("reconcile on deletion returned error: %v", err)
1231+
}
1232+
1233+
// Both PolicyBindings should now be gone.
1234+
for _, ref := range []iamv1alpha1.RoleReference{invitationRoleRef, acceptRoleRef} {
1235+
pbName := getDeterministicRoleName(&ref, *ui)
1236+
if err := c.Get(ctx, types.NamespacedName{Name: pbName, Namespace: ref.Namespace}, &iamv1alpha1.PolicyBinding{}); err == nil {
1237+
t.Fatalf("expected PolicyBinding %s to be deleted by finalizer, but it still exists", pbName)
1238+
} else if !apierr.IsNotFound(err) {
1239+
t.Fatalf("unexpected error checking PolicyBinding %s: %v", pbName, err)
1240+
}
1241+
}
1242+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
apiVersion: resourcemanager.miloapis.com/v1alpha1
2+
kind: Organization
3+
metadata:
4+
name: ui-gc-test-org
5+
labels:
6+
test.miloapis.com/scenario: "userinvitation-policybinding-gc"
7+
annotations:
8+
kubernetes.io/display-name: "UserInvitation GC Test Organization"
9+
spec:
10+
type: Standard
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
apiVersion: iam.miloapis.com/v1alpha1
2+
kind: User
3+
metadata:
4+
name: ui-gc-invitee
5+
labels:
6+
test.miloapis.com/scenario: "userinvitation-policybinding-gc"
7+
spec:
8+
email: invitee@ui-gc-test.local
9+
givenName: Invitee
10+
familyName: User
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
apiVersion: iam.miloapis.com/v1alpha1
2+
kind: UserInvitation
3+
metadata:
4+
name: ui-gc-test-invitation
5+
namespace: organization-ui-gc-test-org
6+
labels:
7+
test.miloapis.com/scenario: "userinvitation-policybinding-gc"
8+
spec:
9+
organizationRef:
10+
name: ui-gc-test-org
11+
email: invitee@ui-gc-test.local
12+
givenName: Invitee
13+
familyName: User
14+
roles:
15+
- name: resourcemanager.miloapis.com-organization-viewer
16+
namespace: milo-system
17+
invitedBy:
18+
name: admin
19+
state: Pending

0 commit comments

Comments
 (0)