diff --git a/app/controlplane/pkg/biz/project_integration_test.go b/app/controlplane/pkg/biz/project_integration_test.go index 5d7e28493..72fdc8456 100644 --- a/app/controlplane/pkg/biz/project_integration_test.go +++ b/app/controlplane/pkg/biz/project_integration_test.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -185,6 +185,18 @@ func (s *projectMembersIntegrationTestSuite) TestListMembers() { s.Error(err) s.True(biz.IsNotFound(err)) }) + + s.Run("tolerates orphaned user memberships", func() { + // Bypass the app-level cascade so the project membership row is left dangling. + err := s.Data.DB.User.DeleteOneID(uuid.MustParse(user2.ID)).Exec(ctx) + require.NoError(s.T(), err) + + members, count, err := s.Project.ListMembers(ctx, uuid.MustParse(s.org.ID), projectRef, nil) + s.NoError(err) + s.Equal(1, len(members)) + s.Equal(1, count, "totalCount should reflect skipped orphan") + s.Equal(user3.ID, members[0].User.ID) + }) } // Test adding members to projects @@ -1229,6 +1241,43 @@ func (s *projectGroupMembersIntegrationTestSuite) TestAddGroupToProject() { }) } +func (s *projectGroupMembersIntegrationTestSuite) TestListMembersToleratesOrphanedGroups() { + ctx := context.Background() + + projectID := s.project.ID + projectRef := &biz.IdentityReference{ID: &projectID} + + _, err := s.Project.AddMemberToProject(ctx, uuid.MustParse(s.org.ID), &biz.AddMemberToProjectOpts{ + ProjectReference: projectRef, + GroupReference: &biz.IdentityReference{ID: &s.group.ID}, + RequesterID: *s.userUUID, + Role: authz.RoleProjectViewer, + }) + require.NoError(s.T(), err) + + survivingGroup, err := s.Group.Create(ctx, uuid.MustParse(s.org.ID), "surviving-group", "kept", s.userUUID) + require.NoError(s.T(), err) + + _, err = s.Project.AddMemberToProject(ctx, uuid.MustParse(s.org.ID), &biz.AddMemberToProjectOpts{ + ProjectReference: projectRef, + GroupReference: &biz.IdentityReference{ID: &survivingGroup.ID}, + RequesterID: *s.userUUID, + Role: authz.RoleProjectAdmin, + }) + require.NoError(s.T(), err) + + // Bypass the app-level cascade in MembershipRepo.Delete so the membership row dangles. + err = s.Data.DB.Group.DeleteOneID(s.group.ID).Exec(ctx) + require.NoError(s.T(), err) + + members, count, err := s.Project.ListMembers(ctx, uuid.MustParse(s.org.ID), projectRef, nil) + s.NoError(err) + s.Equal(1, len(members)) + s.Equal(1, count, "totalCount should reflect skipped orphan") + s.NotNil(members[0].Group) + s.Equal(survivingGroup.ID, members[0].Group.ID) +} + // Test removing groups from projects func (s *projectGroupMembersIntegrationTestSuite) TestRemoveGroupFromProject() { ctx := context.Background() diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260430223706.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260430223706.sql new file mode 100644 index 000000000..976fe033f --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260430223706.sql @@ -0,0 +1,13 @@ +-- atlas:txmode none + +-- One-shot cleanup of orphaned memberships. +-- memberships.member_id is polymorphic and has no FK to users/groups, so deletes +-- that bypass the app-level cascade leave project/product membership rows pointing +-- at vanished users or groups. +DELETE FROM "memberships" +WHERE "membership_type" = 'user' + AND NOT EXISTS (SELECT 1 FROM "users" u WHERE u."id" = "memberships"."member_id"); + +DELETE FROM "memberships" +WHERE "membership_type" = 'group' + AND NOT EXISTS (SELECT 1 FROM "groups" g WHERE g."id" = "memberships"."member_id"); diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index 36d824e5b..c6bff305d 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum +++ b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:Mkh9OlAmAX9pbzA/X2mdwp/bouuIaDWHcTzH8DMHfOw= +h1:qBVt7XbDlg3MT/tOTxZ6AwotFWXacU/xaweG4KC9WXQ= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -130,3 +130,4 @@ h1:Mkh9OlAmAX9pbzA/X2mdwp/bouuIaDWHcTzH8DMHfOw= 20260408122048.sql h1:imfswpfmBlpP1l149/wCLN5HkN3/sGIQ3GnxaSnwOZE= 20260416153232.sql h1:xjEfZuMOo1lgZm3VUYGHpNOhpJixncVZuMRg0jiH+7A= 20260418100730.sql h1:lLcPDneBlzyabUAOIEKqCJgtnklHGac4JUnnZAbyD1g= +20260430223706.sql h1:2mpbhdB4JWfMY8w9J8TGUj/O3tIT7dQpWfugTTI1eEQ= diff --git a/app/controlplane/pkg/data/project.go b/app/controlplane/pkg/data/project.go index d8640f6bd..17f368680 100644 --- a/app/controlplane/pkg/data/project.go +++ b/app/controlplane/pkg/data/project.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -153,21 +153,26 @@ func (r *ProjectRepo) ListMembers(ctx context.Context, orgID uuid.UUID, projectI switch m.MembershipType { case authz.MembershipTypeUser: - // Fetch the user details for user memberships u, uErr := r.data.DB.User.Get(ctx, m.MemberID) if uErr != nil { + // Skip orphaned rows whose member_id no longer points at a real user. + // memberships.member_id is polymorphic with no FK, so deletes that bypass + // the app-level cascade can leave dangling rows here. if ent.IsNotFound(uErr) { - return nil, 0, biz.NewErrNotFound("user") + r.log.Warnf("orphaned project membership %s references missing user %s, skipping", m.ID, m.MemberID) + totalCount-- + continue } return nil, 0, fmt.Errorf("failed to find user: %w", uErr) } mems = entProjectMembershipToBiz(m, u, nil) case authz.MembershipTypeGroup: - // Fetch the group details for group memberships g, gErr := r.data.DB.Group.Get(ctx, m.MemberID) if gErr != nil { if ent.IsNotFound(gErr) { - return nil, 0, biz.NewErrNotFound("group") + r.log.Warnf("orphaned project membership %s references missing group %s, skipping", m.ID, m.MemberID) + totalCount-- + continue } return nil, 0, fmt.Errorf("failed to find group: %w", gErr) }