Skip to content

Commit 33ceab3

Browse files
authored
Clean up organization memberships when users are deleted (#544)
## Summary - Adds a finalizer to the UserController that deletes all OrganizationMemberships referencing a user before allowing the User object to be removed - Extends the OrganizationMembership validation webhook to allow last-owner membership deletion when the referenced User is being deleted (verified via direct API server read, not cache) - Fixes the Organization webhook and UserInvitation controller to set correct User ownerReferences on OrganizationMemberships - Adds a two-pass self-delete in the OrganizationMembership controller for existing orphaned memberships where the User no longer exists ## Context When a User was deleted, OrganizationMembership resources referencing that user were not cleaned up because they had no User ownerReference (webhook creation path) or had a malformed one (invitation path used `.Group` instead of `.String()` for APIVersion). The orphaned memberships left their owned PolicyBindings stuck in `SubjectValidationFailed` state. Closes #536 ## Test plan - [x] Webhook tests verify deletion is allowed when User has DeletionTimestamp - [x] Webhook tests verify deletion is allowed when User is already gone - [x] Webhook tests verify last-owner guard still blocks when User is active - [x] All existing unit tests pass - [x] Chainsaw e2e test validates full lifecycle: create user + org → verify membership → delete user → 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 b782a43 + 485ee94 commit 33ceab3

11 files changed

Lines changed: 474 additions & 5 deletions

File tree

config/controller-manager/overlays/core-control-plane/rbac/role.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ rules:
9494
resources:
9595
- groups/finalizers
9696
- platforminvitations/status
97+
- userinvitations/finalizers
9798
- userinvitations/status
9899
verbs:
99100
- update
@@ -149,6 +150,7 @@ rules:
149150
- delete
150151
- get
151152
- list
153+
- patch
152154
- update
153155
- watch
154156
- apiGroups:
@@ -295,6 +297,7 @@ rules:
295297
- organizationmemberships
296298
verbs:
297299
- create
300+
- delete
298301
- get
299302
- list
300303
- watch

internal/controllers/iam/user_controller.go

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,24 @@ import (
66
"strings"
77

88
iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1"
9+
resourcemanagerv1alpha1 "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1"
910
"k8s.io/apimachinery/pkg/api/equality"
1011
apierrors "k8s.io/apimachinery/pkg/api/errors"
1112
"k8s.io/apimachinery/pkg/api/meta"
1213
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1314
"k8s.io/apimachinery/pkg/types"
1415
ctrl "sigs.k8s.io/controller-runtime"
1516
"sigs.k8s.io/controller-runtime/pkg/client"
17+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
1618
"sigs.k8s.io/controller-runtime/pkg/handler"
1719
"sigs.k8s.io/controller-runtime/pkg/log"
1820
"sigs.k8s.io/controller-runtime/pkg/reconcile"
1921
)
2022

2123
const (
22-
userFinalizerKey = "iam.miloapis.com/user"
24+
// userMembershipCleanupFinalizer ensures OrganizationMembership resources are
25+
// deleted before the User object is removed from the API server.
26+
userMembershipCleanupFinalizer = "iam.miloapis.com/user-membership-cleanup"
2327
userReadyConditionType = "Ready"
2428
platformAccessApprovalIndexKey = "iam.miloapis.com/platformaccessapprovalkey"
2529
platformAccessRejectionIndexKey = "iam.miloapis.com/platformaccessrejectionkey"
@@ -44,6 +48,7 @@ type UserController struct {
4448
// +kubebuilder:rbac:groups=iam.miloapis.com,resources=userpreferences,verbs=get;list;watch;update;patch
4549
// +kubebuilder:rbac:groups=iam.miloapis.com,resources=platformaccessapprovals,verbs=get;list;watch
4650
// +kubebuilder:rbac:groups=iam.miloapis.com,resources=platformaccessrejections,verbs=get;list;watch
51+
// +kubebuilder:rbac:groups=resourcemanager.miloapis.com,resources=organizationmemberships,verbs=list;delete
4752

4853
// Reconcile is the main reconciliation loop for the UserController.
4954
func (r *UserController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
@@ -59,12 +64,31 @@ func (r *UserController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.
5964
}
6065
log.Info("reconciling User", "user", user.Name)
6166

62-
// Stop reconciling if deletion in progress.
67+
// When the user is being deleted, clean up OrganizationMembership resources
68+
// before the object is removed.
6369
if !user.DeletionTimestamp.IsZero() {
64-
log.Info("User is being deleted, skipping reconciliation", "user", user.Name)
70+
if controllerutil.ContainsFinalizer(user, userMembershipCleanupFinalizer) {
71+
if err := r.cleanupOrganizationMemberships(ctx, user); err != nil {
72+
log.Error(err, "failed to clean up OrganizationMemberships during user deletion")
73+
return ctrl.Result{}, err
74+
}
75+
controllerutil.RemoveFinalizer(user, userMembershipCleanupFinalizer)
76+
if err := r.Client.Update(ctx, user); err != nil {
77+
return ctrl.Result{}, fmt.Errorf("failed to remove membership cleanup finalizer: %w", err)
78+
}
79+
}
6580
return ctrl.Result{}, nil
6681
}
6782

83+
// Ensure the membership-cleanup finalizer is present on every active User so
84+
// that OrganizationMemberships are deleted before the User is removed.
85+
if !controllerutil.ContainsFinalizer(user, userMembershipCleanupFinalizer) {
86+
controllerutil.AddFinalizer(user, userMembershipCleanupFinalizer)
87+
if err := r.Client.Update(ctx, user); err != nil {
88+
return ctrl.Result{}, fmt.Errorf("failed to add membership cleanup finalizer: %w", err)
89+
}
90+
}
91+
6892
// Ensure owner references are set on PolicyBinding and UserPreference resources
6993
if err := r.ensureOwnerReferences(ctx, user); err != nil {
7094
log.Error(err, "Failed to ensure owner references")
@@ -237,6 +261,33 @@ func (r *UserController) ensureOwnerReferences(ctx context.Context, user *iamv1a
237261
return nil
238262
}
239263

264+
// cleanupOrganizationMemberships deletes all OrganizationMembership resources that
265+
// reference the given user. This is called when a user is being deleted so that
266+
// memberships (including last-owner memberships) are removed before the User
267+
// object is garbage-collected.
268+
//
269+
// Depends on the "spec.userRef.name" field index registered by
270+
// OrganizationMembershipController.SetupWithManager. Both controllers share the
271+
// same manager, so the index is available when this function runs.
272+
func (r *UserController) cleanupOrganizationMemberships(ctx context.Context, user *iamv1alpha1.User) error {
273+
log := log.FromContext(ctx).WithName("cleanup-organization-memberships")
274+
275+
var membershipList resourcemanagerv1alpha1.OrganizationMembershipList
276+
if err := r.Client.List(ctx, &membershipList, client.MatchingFields{"spec.userRef.name": user.Name}); err != nil {
277+
return fmt.Errorf("failed to list OrganizationMemberships for user %s: %w", user.Name, err)
278+
}
279+
280+
for i := range membershipList.Items {
281+
membership := &membershipList.Items[i]
282+
log.Info("deleting OrganizationMembership for deleted user", "membership", membership.Name, "namespace", membership.Namespace)
283+
if err := r.Client.Delete(ctx, membership); err != nil && !apierrors.IsNotFound(err) {
284+
return fmt.Errorf("failed to delete OrganizationMembership %s/%s: %w", membership.Namespace, membership.Name, err)
285+
}
286+
}
287+
288+
return nil
289+
}
290+
240291
// hasOwnerReference checks if the owner reference already exists
241292
func hasOwnerReference(refs []metav1.OwnerReference, ref metav1.OwnerReference) bool {
242293
for _, r := range refs {

internal/controllers/iam/userinvitation_controller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,7 @@ func (r *UserInvitationController) createOrganizationMembership(ctx context.Cont
555555
Namespace: fmt.Sprintf("organization-%s", ui.Spec.OrganizationRef.Name),
556556
OwnerReferences: []metav1.OwnerReference{
557557
{
558-
APIVersion: iamv1alpha1.SchemeGroupVersion.Group,
558+
APIVersion: iamv1alpha1.SchemeGroupVersion.String(),
559559
Kind: "User",
560560
Name: user.GetName(),
561561
UID: user.GetUID(),

internal/controllers/resourcemanager/organization_membership_controller.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,22 @@ func (r *OrganizationMembershipController) Reconcile(ctx context.Context, req ct
131131
if err := r.Client.Get(ctx, userKey, &user); err != nil {
132132
if apierrors.IsNotFound(err) {
133133
logger.Info("referenced user not found", "user", organizationMembership.Spec.UserRef.Name)
134+
135+
// Two-pass self-delete: if we already set UserNotFound on a previous
136+
// reconcile, delete the membership now so it does not linger after
137+
// the owning User is gone. The second reconcile is triggered by the
138+
// status condition update below, which re-enqueues the membership.
139+
existingCondition := apimeta.FindStatusCondition(organizationMembership.Status.Conditions, OrganizationMembershipReady)
140+
if existingCondition != nil && existingCondition.Reason == UserNotFoundReason {
141+
logger.Info("deleting OrganizationMembership because referenced user no longer exists",
142+
"membership", organizationMembership.Name,
143+
"user", organizationMembership.Spec.UserRef.Name)
144+
if err := r.Client.Delete(ctx, &organizationMembership); err != nil && !apierrors.IsNotFound(err) {
145+
return ctrl.Result{}, fmt.Errorf("failed to self-delete organization membership: %w", err)
146+
}
147+
return ctrl.Result{}, nil
148+
}
149+
134150
readyCondition.Status = metav1.ConditionFalse
135151
readyCondition.Reason = UserNotFoundReason
136152
readyCondition.Message = fmt.Sprintf("User '%s' does not exist. Please ensure the user name is correct and the user account has been created.", organizationMembership.Spec.UserRef.Name)

internal/webhooks/resourcemanager/v1alpha1/organization_webhook.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,14 @@ func (v *OrganizationValidator) createOrganizationMembership(ctx context.Context
181181
ObjectMeta: metav1.ObjectMeta{
182182
Name: fmt.Sprintf("member-%s", user.Name),
183183
Namespace: fmt.Sprintf("organization-%s", org.Name),
184+
OwnerReferences: []metav1.OwnerReference{
185+
{
186+
APIVersion: iamv1alpha1.SchemeGroupVersion.String(),
187+
Kind: "User",
188+
Name: user.Name,
189+
UID: user.UID,
190+
},
191+
},
184192
},
185193
Spec: resourcemanagerv1alpha1.OrganizationMembershipSpec{
186194
OrganizationRef: resourcemanagerv1alpha1.OrganizationReference{

internal/webhooks/resourcemanager/v1alpha1/organizationmembership_webhook.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func SetupOrganizationMembershipWebhooksWithManager(mgr ctrl.Manager, organizati
3030
For(&resourcemanagerv1alpha1.OrganizationMembership{}).
3131
WithValidator(&OrganizationMembershipValidator{
3232
client: mgr.GetClient(),
33+
apiReader: mgr.GetAPIReader(),
3334
ownerRoleName: organizationOwnerRoleName,
3435
ownerRoleNamespace: organizationOwnerRoleNamespace,
3536
}).
@@ -39,6 +40,7 @@ func SetupOrganizationMembershipWebhooksWithManager(mgr ctrl.Manager, organizati
3940
// OrganizationMembershipValidator validates OrganizationMemberships
4041
type OrganizationMembershipValidator struct {
4142
client client.Client
43+
apiReader client.Reader
4244
decoder admission.Decoder
4345
ownerRoleName string
4446
ownerRoleNamespace string
@@ -85,6 +87,13 @@ func (v *OrganizationMembershipValidator) ValidateDelete(ctx context.Context, ob
8587
return nil, nil
8688
}
8789

90+
// Allow deletion when the referenced user is itself being deleted — the
91+
// UserController is responsible for cleaning up orphaned memberships and
92+
// must not be blocked by the last-owner guard.
93+
if v.allowDeletionBecauseUserIsBeingDeleted(ctx, membership) {
94+
return nil, nil
95+
}
96+
8897
if v.allowOwnerDeletionDuringTeardown(ctx, membership) {
8998
return nil, nil
9099
}
@@ -141,6 +150,33 @@ func (v *OrganizationMembershipValidator) allowOwnerDeletionDuringTeardown(ctx c
141150
return organization.DeletionTimestamp != nil
142151
}
143152

153+
// allowDeletionBecauseUserIsBeingDeleted returns true when the membership should be
154+
// allowed to be deleted because the referenced user has a non-zero DeletionTimestamp.
155+
// The UserController adds a finalizer and drives membership cleanup; we must not
156+
// block it with the last-owner guard.
157+
//
158+
// Uses the direct API reader (not the informer cache) to avoid stale reads that
159+
// could incorrectly bypass the last-owner business invariant.
160+
func (v *OrganizationMembershipValidator) allowDeletionBecauseUserIsBeingDeleted(ctx context.Context, membership *resourcemanagerv1alpha1.OrganizationMembership) bool {
161+
userName := membership.Spec.UserRef.Name
162+
if userName == "" {
163+
return false
164+
}
165+
166+
var user iamv1alpha1.User
167+
if err := v.apiReader.Get(ctx, client.ObjectKey{Name: userName}, &user); err != nil {
168+
if apierrors.IsNotFound(err) {
169+
// User is already gone — allow the membership deletion.
170+
return true
171+
}
172+
organizationmembershiplog.Error(err, "failed to fetch user while validating delete",
173+
"user", userName)
174+
return false
175+
}
176+
177+
return user.DeletionTimestamp != nil
178+
}
179+
144180
// isNamespaceTerminating returns true if the namespace is terminating or already deleted.
145181
func (v *OrganizationMembershipValidator) isNamespaceTerminating(ctx context.Context, namespace string) bool {
146182
if namespace == "" {

0 commit comments

Comments
 (0)