From 26babca1d33b66410205fa6da3f2eac34c0f124f Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Tue, 15 Jul 2025 08:03:29 +0200 Subject: [PATCH] fix(group): Deletion of groups handle correctly Signed-off-by: Javier Rodriguez --- .../ent/migrate/migrations/20250714172256.sql | 44 ++++++++++++ .../pkg/data/ent/migrate/migrations/atlas.sum | 3 +- app/controlplane/pkg/data/group.go | 70 ++++++++++++++++--- app/controlplane/pkg/data/project.go | 4 +- 4 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20250714172256.sql diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20250714172256.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20250714172256.sql new file mode 100644 index 000000000..141d47605 --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20250714172256.sql @@ -0,0 +1,44 @@ +-- Fix memberships for deleted groups +-- This migration does the following: +-- 1. Removes entries from the membership table where the member_id is a deleted group or resource_id is a deleted group +-- 2. Marks group memberships with deleted group IDs as deleted (sets deleted_at) +-- 3. Marks as deleted any pending invitations for deleted groups + +-- First, delete all memberships where the member is a deleted group or the resource is a deleted group +DELETE FROM "memberships" +WHERE "member_id" IN ( + SELECT "id" FROM "groups" + WHERE "deleted_at" IS NOT NULL +) OR ( + "resource_id" IN ( + SELECT "id" FROM "groups" + WHERE "deleted_at" IS NOT NULL + ) + AND "resource_type" = 'group' +); + +-- Next, mark all group_memberships as deleted for deleted groups +-- Only update records that don't already have a deleted_at value +UPDATE "group_memberships" +SET + "deleted_at" = NOW(), + "updated_at" = NOW() +WHERE + "group_id" IN ( + SELECT "id" FROM "groups" + WHERE "deleted_at" IS NOT NULL + ) + AND "deleted_at" IS NULL; + +-- Finally, ark as deleted any pending invitations for deleted groups +UPDATE "org_invitations" +SET + "deleted_at" = NOW() +WHERE + "status" = 'pending' + AND "deleted_at" IS NULL + AND "context"::jsonb ? 'group_id_to_join' + AND "context"::jsonb->>'group_id_to_join' IN ( + SELECT "id"::text FROM "groups" + WHERE "deleted_at" IS NOT NULL +); diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index 887b83c74..8e22a2ad1 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:i8q/MAG0rdlc1GCi0fAagiO70ZEhj6wvvFp8kgM+l50= +h1:WasPyGuTE05lx75h+qqmoAknSDJPoN50E8hqbhNFVQs= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -96,3 +96,4 @@ h1:i8q/MAG0rdlc1GCi0fAagiO70ZEhj6wvvFp8kgM+l50= 20250702112642.sql h1:wrjVS+5h2hs7KNwPRBece5LgAsoEzxN/zNfvCnjoIUw= 20250704090359.sql h1:a0ksfjy2dtzviJL16HbC4eT1xBxy2qFH5mNFOpYlUrA= 20250710105502.sql h1:EA6Ta1qsZcrNoOrO5zUNgiweHDtjl0HUlobukRuruko= +20250714172256.sql h1:S0ImNk0sMjWVVZvS6VVHn2h96/nx8GOf4aVxELbJAcg= diff --git a/app/controlplane/pkg/data/group.go b/app/controlplane/pkg/data/group.go index 32e745f23..4366724e2 100644 --- a/app/controlplane/pkg/data/group.go +++ b/app/controlplane/pkg/data/group.go @@ -369,20 +369,68 @@ func (g GroupRepo) Update(ctx context.Context, orgID uuid.UUID, groupID uuid.UUI } // SoftDelete soft-deletes a group by setting the DeletedAt field to the current time. +// It also marks all group memberships as deleted and removes any pending invitations related to the group. func (g GroupRepo) SoftDelete(ctx context.Context, orgID uuid.UUID, groupID uuid.UUID) error { - // Softly delete the group by setting the DeletedAt field - _, err := g.data.DB.Group.UpdateOneID(groupID). - SetDeletedAt(time.Now()). - Where(group.OrganizationIDEQ(orgID), group.DeletedAtIsNil()). - Save(ctx) - if err != nil { - if ent.IsNotFound(err) { - return biz.NewErrNotFound("group") + return WithTx(ctx, g.data.DB, func(tx *ent.Tx) error { + now := time.Now() + + // Softly delete the group by setting the DeletedAt field + _, err := tx.Group.UpdateOneID(groupID). + SetDeletedAt(now). + Where(group.OrganizationIDEQ(orgID), group.DeletedAtIsNil()). + Save(ctx) + if err != nil { + if ent.IsNotFound(err) { + return biz.NewErrNotFound("group") + } + return fmt.Errorf("failed to mark group as deleted: %w", err) } - return err - } - return nil + // Mark as deleted all group memberships for this group + _, err = tx.GroupMembership.Update(). + Where( + groupmembership.GroupID(groupID), + groupmembership.DeletedAtIsNil(), + ). + SetDeletedAt(now). + Save(ctx) + if err != nil && !ent.IsNotFound(err) { + return fmt.Errorf("failed to mark group memberships as deleted: %w", err) + } + + // Delete all memberships where this group is either the member or the resource + _, err = tx.Membership.Delete().Where( + membership.HasOrganizationWith(organization.ID(orgID)), + membership.Or( + membership.MemberID(groupID), + membership.And( + membership.ResourceID(groupID), + membership.ResourceTypeEQ(authz.ResourceTypeGroup), + ), + ), + ).Exec(ctx) + if err != nil && !ent.IsNotFound(err) { + return fmt.Errorf("failed to delete group memberships: %w", err) + } + + // Mark as deleted any pending invitations for this group + _, err = tx.OrgInvitation.Update(). + Where( + orginvitation.OrganizationIDEQ(orgID), + orginvitation.DeletedAtIsNil(), + orginvitation.StatusEQ(biz.OrgInvitationStatusPending), + func(s *sql.Selector) { + s.Where(sqljson.ValueEQ(orginvitation.FieldContext, groupID.String(), sqljson.DotPath("group_id_to_join"))) + }, + ). + SetDeletedAt(now). + Save(ctx) + if err != nil { + return fmt.Errorf("failed to cancel pending invitations for deleted group: %w", err) + } + + return nil + }) } // AddMemberToGroup adds a user to a group, creating a new membership if they are not already a member. diff --git a/app/controlplane/pkg/data/project.go b/app/controlplane/pkg/data/project.go index 2da14b3b0..ca5c38470 100644 --- a/app/controlplane/pkg/data/project.go +++ b/app/controlplane/pkg/data/project.go @@ -20,6 +20,8 @@ import ( "fmt" "time" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/group" + "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqljson" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" @@ -277,7 +279,7 @@ func (r *ProjectRepo) FindProjectMembershipByProjectAndID(ctx context.Context, o projectMembership.User = entUserToBizUser(u) case authz.MembershipTypeGroup: // Fetch the group details for group memberships - g, err := r.data.DB.Group.Get(ctx, memberID) + g, err := r.data.DB.Group.Query().Where(group.ID(memberID), group.DeletedAtIsNil()).Only(ctx) if err != nil { if ent.IsNotFound(err) { return nil, biz.NewErrNotFound("group")