Skip to content

Commit 017ce4e

Browse files
committed
fix(membership): Removing a user from the org should cleanup resources
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
1 parent 176b703 commit 017ce4e

3 files changed

Lines changed: 308 additions & 1 deletion

File tree

app/controlplane/pkg/biz/membership.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ func (uc *MembershipUseCase) DeleteOther(ctx context.Context, orgID, userID, mem
154154
}
155155

156156
uc.logger.Infow("msg", "Deleting membership", "org_id", orgID, "membership_id", m.ID.String())
157+
158+
// Delete the main membership - this will also remove the user from all groups in the org
159+
// and clean up associated resource memberships in the data layer
157160
if err := uc.repo.Delete(ctx, membershipUUID); err != nil {
158161
return fmt.Errorf("failed to delete membership: %w", err)
159162
}

app/controlplane/pkg/biz/membership_integration_test.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,215 @@ func (s *membershipIntegrationTestSuite) TestCreateMembership() {
277277
})
278278
}
279279

280+
func (s *membershipIntegrationTestSuite) TestDeleteCleanup() {
281+
ctx := context.Background()
282+
283+
// Create users
284+
user, err := s.User.UpsertByEmail(ctx, "cleanup-test@example.com", nil)
285+
s.NoError(err)
286+
adminUser, err := s.User.UpsertByEmail(ctx, "admin-user@example.com", nil)
287+
s.NoError(err)
288+
289+
// Create an organization
290+
org, err := s.Organization.CreateWithRandomName(ctx)
291+
s.NoError(err)
292+
293+
// Add users to organization with different roles
294+
membershipUser, err := s.Membership.Create(ctx, org.ID, user.ID, biz.WithCurrentMembership())
295+
s.NoError(err)
296+
_, err = s.Membership.Create(ctx, org.ID, adminUser.ID, biz.WithMembershipRole(authz.RoleAdmin), biz.WithCurrentMembership())
297+
s.NoError(err)
298+
299+
// Create a project in the organization
300+
project, err := s.Project.Create(ctx, org.ID, "test-cleanup-project")
301+
s.NoError(err)
302+
303+
// Add user to the project
304+
projectRef := &biz.IdentityReference{ID: &project.ID}
305+
projectOpts := &biz.AddMemberToProjectOpts{
306+
ProjectReference: projectRef,
307+
UserEmail: "cleanup-test@example.com",
308+
RequesterID: uuid.MustParse(adminUser.ID),
309+
Role: authz.RoleProjectViewer,
310+
}
311+
_, err = s.Project.AddMemberToProject(ctx, uuid.MustParse(org.ID), projectOpts)
312+
s.NoError(err)
313+
314+
// Create a group in the organization
315+
userUUID := uuid.MustParse(user.ID)
316+
group, err := s.Group.Create(ctx, uuid.MustParse(org.ID), "test-cleanup-group", "Group for cleanup testing", &userUUID)
317+
s.NoError(err)
318+
319+
// Create another user to add to the group
320+
otherUser, err := s.User.UpsertByEmail(ctx, "other-member@example.com", nil)
321+
s.NoError(err)
322+
_, err = s.Membership.Create(ctx, org.ID, otherUser.ID)
323+
s.NoError(err)
324+
325+
// Add other user to the group
326+
groupRef := &biz.IdentityReference{ID: &group.ID}
327+
groupOpts := &biz.AddMemberToGroupOpts{
328+
IdentityReference: groupRef,
329+
UserEmail: "other-member@example.com",
330+
RequesterID: uuid.MustParse(user.ID),
331+
Maintainer: false,
332+
}
333+
_, err = s.Group.AddMemberToGroup(ctx, uuid.MustParse(org.ID), groupOpts)
334+
s.NoError(err)
335+
336+
// Verify initial state
337+
s.Run("verify initial state", func() {
338+
// Verify user is in the project
339+
members, count, err := s.Project.ListMembers(ctx, uuid.MustParse(org.ID), projectRef, nil)
340+
s.NoError(err)
341+
s.Equal(1, count)
342+
s.Equal(1, len(members))
343+
s.Equal(user.ID, members[0].User.ID)
344+
345+
// Verify user is in the group as maintainer
346+
groupMembers, groupCount, err := s.Group.ListMembers(ctx, uuid.MustParse(org.ID), &biz.ListMembersOpts{
347+
IdentityReference: groupRef,
348+
}, nil)
349+
s.NoError(err)
350+
s.Equal(2, groupCount) // User + otherUser
351+
userFound := false
352+
for _, member := range groupMembers {
353+
if member.User.ID == user.ID {
354+
s.True(member.Maintainer)
355+
userFound = true
356+
break
357+
}
358+
}
359+
s.True(userFound, "User should be found in the group as a maintainer")
360+
})
361+
362+
// Delete the user's membership
363+
s.Run("delete user membership", func() {
364+
err := s.Membership.LeaveAndDeleteOrg(ctx, user.ID, membershipUser.ID.String())
365+
s.NoError(err)
366+
367+
// Check that the organization still exists (since there's still admin user)
368+
_, err = s.Organization.FindByID(ctx, org.ID)
369+
s.NoError(err)
370+
371+
// Verify user is removed from project
372+
projectMembers, projectCount, err := s.Project.ListMembers(ctx, uuid.MustParse(org.ID), projectRef, nil)
373+
s.NoError(err)
374+
s.Equal(0, projectCount)
375+
s.Equal(0, len(projectMembers))
376+
377+
// Verify user is removed from group but other member remains
378+
groupMembers, groupCount, err := s.Group.ListMembers(ctx, uuid.MustParse(org.ID), &biz.ListMembersOpts{
379+
IdentityReference: groupRef,
380+
}, nil)
381+
s.NoError(err)
382+
s.Equal(1, groupCount) // Only otherUser should remain
383+
s.Equal(1, len(groupMembers))
384+
s.Equal(otherUser.ID, groupMembers[0].User.ID)
385+
s.False(groupMembers[0].Maintainer)
386+
387+
// Verify group membership has been decremented
388+
updatedGroup, err := s.Group.Get(ctx, uuid.MustParse(org.ID), &biz.IdentityReference{ID: &group.ID})
389+
s.NoError(err)
390+
s.Equal(1, updatedGroup.MemberCount)
391+
})
392+
}
393+
394+
func (s *membershipIntegrationTestSuite) TestDeleteWithGroups() {
395+
ctx := context.Background()
396+
397+
// Create a user
398+
user, err := s.User.UpsertByEmail(ctx, "groups-test@example.com", nil)
399+
s.NoError(err)
400+
userUUID := uuid.MustParse(user.ID)
401+
402+
// Create an organization
403+
org, err := s.Organization.CreateWithRandomName(ctx)
404+
s.NoError(err)
405+
orgUUID := uuid.MustParse(org.ID)
406+
407+
// Add user to organization
408+
membershipUser, err := s.Membership.Create(ctx, org.ID, user.ID, biz.WithMembershipRole(authz.RoleAdmin), biz.WithCurrentMembership())
409+
s.NoError(err)
410+
411+
// Create multiple groups with the user as maintainer
412+
group1, err := s.Group.Create(ctx, orgUUID, "group-1", "First group", &userUUID)
413+
s.NoError(err)
414+
group2, err := s.Group.Create(ctx, orgUUID, "group-2", "Second group", &userUUID)
415+
s.NoError(err)
416+
417+
// Add the groups to a project
418+
pr, err := s.Project.Create(ctx, org.ID, "test-groups-project")
419+
s.NoError(err)
420+
projectRef := &biz.IdentityReference{ID: &pr.ID}
421+
422+
// Add group1 to project
423+
groupProjectOpts := &biz.AddMemberToProjectOpts{
424+
ProjectReference: projectRef,
425+
GroupReference: &biz.IdentityReference{ID: &group1.ID},
426+
RequesterID: userUUID,
427+
Role: authz.RoleProjectAdmin,
428+
}
429+
_, err = s.Project.AddMemberToProject(ctx, orgUUID, groupProjectOpts)
430+
s.NoError(err)
431+
432+
// Verify initial state
433+
s.Run("verify initial state with groups", func() {
434+
// Check user is a maintainer in both groups
435+
group1Members, _, err := s.Group.ListMembers(ctx, orgUUID, &biz.ListMembersOpts{
436+
IdentityReference: &biz.IdentityReference{ID: &group1.ID},
437+
}, nil)
438+
s.NoError(err)
439+
s.Equal(1, len(group1Members))
440+
s.Equal(user.ID, group1Members[0].User.ID)
441+
s.True(group1Members[0].Maintainer)
442+
443+
group2Members, _, err := s.Group.ListMembers(ctx, orgUUID, &biz.ListMembersOpts{
444+
IdentityReference: &biz.IdentityReference{ID: &group2.ID},
445+
}, nil)
446+
s.NoError(err)
447+
s.Equal(1, len(group2Members))
448+
s.Equal(user.ID, group2Members[0].User.ID)
449+
s.True(group2Members[0].Maintainer)
450+
451+
// Check group1 is in the project
452+
projectMembers, _, err := s.Project.ListMembers(ctx, orgUUID, projectRef, nil)
453+
s.NoError(err)
454+
s.Equal(1, len(projectMembers))
455+
s.Equal(group1.ID, projectMembers[0].Group.ID)
456+
})
457+
458+
// Delete the user's membership
459+
s.Run("delete user membership with groups", func() {
460+
err := s.Membership.LeaveAndDeleteOrg(ctx, user.ID, membershipUser.ID.String())
461+
s.NoError(err)
462+
463+
// The organization should be deleted since this was the only user
464+
_, err = s.Organization.FindByID(ctx, org.ID)
465+
s.True(biz.IsNotFound(err), "Organization should be deleted")
466+
467+
// All groups should be soft-deleted
468+
_, err = s.Group.Get(ctx, orgUUID, &biz.IdentityReference{ID: &group1.ID})
469+
s.True(biz.IsNotFound(err), "Group 1 should be deleted")
470+
471+
_, err = s.Group.Get(ctx, orgUUID, &biz.IdentityReference{ID: &group2.ID})
472+
s.True(biz.IsNotFound(err), "Group 2 should be deleted")
473+
474+
// The project should be deleted
475+
_, err = s.Project.FindProjectByReference(ctx, org.ID, &biz.IdentityReference{ID: projectRef.ID})
476+
s.True(biz.IsNotFound(err), "Project should be deleted")
477+
478+
// Verify group memberships are marked as deleted
479+
group1Mem, group1Err := s.Repos.GroupRepo.FindGroupMembershipByGroupAndID(ctx, group1.ID, userUUID)
480+
s.True(biz.IsNotFound(group1Err))
481+
s.Nil(group1Mem)
482+
483+
group2Mem, group2Err := s.Repos.GroupRepo.FindGroupMembershipByGroupAndID(ctx, group2.ID, userUUID)
484+
s.True(biz.IsNotFound(group2Err))
485+
s.Nil(group2Mem)
486+
})
487+
}
488+
280489
// Run the tests
281490
func TestMembershipUseCase(t *testing.T) {
282491
suite.Run(t, new(membershipIntegrationTestSuite))

app/controlplane/pkg/data/membership.go

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ package data
1818
import (
1919
"context"
2020
"fmt"
21+
"time"
22+
23+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/group"
2124

2225
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
2326
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
@@ -240,8 +243,100 @@ func (r *MembershipRepo) SetRole(ctx context.Context, membershipID uuid.UUID, ro
240243
}
241244

242245
// Delete deletes a membership by ID.
246+
// When deleting a membership, it's important to ensure we're not leaving any dangling references.
243247
func (r *MembershipRepo) Delete(ctx context.Context, id uuid.UUID) error {
244-
return r.data.DB.Membership.DeleteOneID(id).Exec(ctx)
248+
var membershipToDelete *ent.Membership
249+
250+
// First, fetch the membership to understand what we're deleting
251+
membershipToDelete, err := r.data.DB.Membership.Query().Where(membership.ID(id)).WithOrganization().Only(ctx)
252+
if err != nil {
253+
if ent.IsNotFound(err) {
254+
return nil // Already deleted, nothing to do
255+
}
256+
return fmt.Errorf("failed to get membership: %w", err)
257+
}
258+
259+
err = WithTx(ctx, r.data.DB, func(tx *ent.Tx) error {
260+
// Delete the specific membership
261+
if err := tx.Membership.DeleteOneID(id).Exec(ctx); err != nil {
262+
return fmt.Errorf("failed to delete membership: %w", err)
263+
}
264+
265+
// If this is an organization membership and the member type is a user,
266+
// we also need to clean up any resource memberships for this user in the organization
267+
if membershipToDelete.ResourceType == authz.ResourceTypeOrganization &&
268+
membershipToDelete.MembershipType == authz.MembershipTypeUser {
269+
270+
// Extract the organization id from the membership
271+
orgID := membershipToDelete.Edges.Organization.ID
272+
273+
// Extract the user ID from the membership
274+
// Note: membershipToDelete.MemberID is already the user ID in this case
275+
userID := membershipToDelete.MemberID
276+
277+
// Delete all other resource memberships for this user in the organization
278+
_, err := tx.Membership.Delete().Where(
279+
membership.IDNEQ(id), // Don't try to delete the one we already deleted
280+
membership.MemberID(userID),
281+
membership.HasOrganizationWith(organization.ID(orgID)),
282+
).Exec(ctx)
283+
284+
if err != nil {
285+
return fmt.Errorf("failed to delete related memberships: %w", err)
286+
}
287+
288+
// Remove the user from all groups in the organization
289+
now := time.Now()
290+
291+
// Find all group memberships for the user in the organization
292+
groupMemberships, err := tx.GroupMembership.Query().Where(
293+
groupmembership.UserID(userID),
294+
groupmembership.DeletedAtIsNil(),
295+
groupmembership.HasGroupWith(group.OrganizationID(orgID)),
296+
).All(ctx)
297+
298+
if err != nil {
299+
return fmt.Errorf("failed to query group memberships: %w", err)
300+
}
301+
302+
for _, gm := range groupMemberships {
303+
// Soft delete each membership
304+
if _, err := tx.GroupMembership.UpdateOne(gm).
305+
SetDeletedAt(now).
306+
SetUpdatedAt(now).
307+
Save(ctx); err != nil {
308+
return fmt.Errorf("failed to remove user from group: %w", err)
309+
}
310+
311+
if gm.Maintainer {
312+
// Also remove the user membership if it exists
313+
if _, err := tx.Membership.Delete().Where(
314+
membership.MemberID(userID),
315+
membership.ResourceTypeEQ(authz.ResourceTypeGroup),
316+
membership.ResourceID(gm.GroupID),
317+
membership.HasOrganizationWith(organization.ID(orgID)),
318+
).Exec(ctx); err != nil {
319+
return fmt.Errorf("failed to remove user from group: %w", err)
320+
}
321+
}
322+
323+
// Decrement the member count of the group
324+
if err := tx.Group.UpdateOneID(gm.GroupID).
325+
AddMemberCount(-1).
326+
Exec(ctx); err != nil {
327+
return fmt.Errorf("failed to decrement group member count: %w", err)
328+
}
329+
}
330+
}
331+
332+
return nil
333+
})
334+
335+
if err != nil {
336+
return fmt.Errorf("failed to delete membership: %w", err)
337+
}
338+
339+
return nil
245340
}
246341

247342
// RBAC methods

0 commit comments

Comments
 (0)