Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions core/deleter/deleter.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,8 @@ func labelWithOrg(orgName string, b Blocker) Blocker {
return b
}

// BlockedError carries every blocker the up-front check found, so the
// caller gets one checklist instead of discovering blockers one retry at
// a time.
// BlockedError carries the blockers that stop a delete, so the caller gets
// one checklist instead of discovering blockers one retry at a time.
type BlockedError struct {
OrgID string
ProjectID string
Expand Down
98 changes: 49 additions & 49 deletions core/deleter/mocks/membership_service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 26 additions & 25 deletions core/deleter/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ type MembershipService interface {
OnGroupDeleted(ctx context.Context, groupID string) error
ListResourcesByPrincipal(ctx context.Context, principal authenticate.Principal, resourceType string, filter membership.ResourceFilter) ([]string, error)
ListPrincipalsByResource(ctx context.Context, resourceID, resourceType string, filter membership.MemberFilter) ([]membership.Member, error)
ForceRemoveOrganizationMember(ctx context.Context, orgID, principalID, principalType string) error
RemoveOrganizationMemberOnDelete(ctx context.Context, orgID, principalID, principalType string) error
RemovePrincipalPolicies(ctx context.Context, principalID, principalType string) error
}

Expand Down Expand Up @@ -542,21 +542,6 @@ func (d Service) deleteCustomers(ctx context.Context, id string, customers []cus
return nil
}

// RemoveUsersFromOrg removes users from an organization as members. The policy
// and relation cleanup — org, project, group, and custom resource — is
// delegated to membership.ForceRemoveOrganizationMember. It uses the force
// variant because a deletion cascade must succeed even when the user is the
// org's last owner.
func (d Service) RemoveUsersFromOrg(ctx context.Context, orgID string, userIDs []string) error {
var errs error
for _, userID := range userIDs {
if memberErr := d.membershipService.ForceRemoveOrganizationMember(ctx, orgID, userID, schema.UserPrincipal); memberErr != nil {
errs = errors.Join(errs, memberErr)
}
}
return errs
}

func (d Service) CheckUserDelete(ctx context.Context, userID string) (UserDeleteCheck, error) {
check, err := d.checkUser(ctx, userID, false)
if err != nil {
Expand Down Expand Up @@ -626,18 +611,22 @@ func (d Service) checkUserOrg(ctx context.Context, userID, orgID string, fromPro
toLeave.RoleTitle = self.Roles[0].Title
}
if isOrgOwner(*self) && otherOwners == 0 {
toLeave.Blockers = []Blocker{{
Type: BlockerSoleOwnerWithMembers,
Subject: org.ID,
SubjectType: SubjectOrganization,
Message: fmt.Sprintf("organization[%s] has other members and this user is its only owner: make another member an owner or remove the other members, then retry the delete", org.Name),
}}
toLeave.Blockers = []Blocker{soleOwnerBlocker(org)}
}
check.OrganizationsToLeave = append(check.OrganizationsToLeave, toLeave)
}
return nil
}

func soleOwnerBlocker(org organization.Organization) Blocker {
return Blocker{
Type: BlockerSoleOwnerWithMembers,
Subject: org.ID,
SubjectType: SubjectOrganization,
Message: fmt.Sprintf("organization[%s] has other members and this user is its only owner: make another member an owner or remove the other members, then retry the delete", org.Name),
}
}

func (d Service) existingOrgUsers(ctx context.Context, orgID string) ([]membership.Member, error) {
members, err := d.membershipService.ListPrincipalsByResource(ctx, orgID, schema.OrganizationNamespace, membership.MemberFilter{
PrincipalType: schema.UserPrincipal,
Expand All @@ -660,8 +649,8 @@ func isOrgOwner(m membership.Member) bool {
return slices.ContainsFunc(m.Roles, func(r role.Role) bool { return r.Name == schema.RoleOrganizationOwner })
}

// DeleteUser visits every org the user has a policy on (disabled orgs too),
// otherwise userService.Delete would leave orphan policy rows behind.
// DeleteUser stops on any blocker the check finds. Otherwise it deletes the orgs
// where the user is the only member and leaves the others, disabled orgs too.
func (d Service) DeleteUser(ctx context.Context, userID string) error {
check, err := d.checkUser(ctx, userID, true)
if err != nil {
Expand All @@ -676,7 +665,7 @@ func (d Service) DeleteUser(ctx context.Context, userID string) error {
}
}
for _, o := range check.OrganizationsToLeave {
if err := d.RemoveUsersFromOrg(ctx, o.Organization.ID, []string{userID}); err != nil {
if err := d.leaveOrg(ctx, userID, o.Organization); err != nil {
Comment thread
rohilsurana marked this conversation as resolved.
return fmt.Errorf("failed to delete user from org[%s]: %w", o.Organization.ID, err)
}
}
Expand All @@ -689,6 +678,18 @@ func (d Service) DeleteUser(ctx context.Context, userID string) error {
return d.userService.Delete(ctx, userID)
}

// leaveOrg runs after the check, and the org can change in between.
func (d Service) leaveOrg(ctx context.Context, userID string, org organization.Organization) error {
err := d.membershipService.RemoveOrganizationMemberOnDelete(ctx, org.ID, userID, schema.UserPrincipal)
switch {
case errors.Is(err, membership.ErrNotMember), errors.Is(err, organization.ErrNotExist):
return nil
case errors.Is(err, membership.ErrLastOwnerRole):
return &BlockedError{UserID: userID, Blockers: []Blocker{soleOwnerBlocker(org)}}
}
return err
}

func (d Service) listOrgProjects(ctx context.Context, orgID string) ([]project.Project, error) {
return d.projService.List(ctx, project.Filter{OrgID: orgID, IncludeDisabled: true})
}
Expand Down
83 changes: 44 additions & 39 deletions core/deleter/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1790,43 +1790,6 @@ func TestDeleteCustomers(t *testing.T) {
})
}

func TestRemoveUsersFromOrg(t *testing.T) {
const orgID = "org-1"
const userID = "user-1"

t.Run("delegates the whole removal to the membership cascade", func(t *testing.T) {
m := newMocks(t)

// org, project, group, and custom-resource cleanup all happen inside the
// cascade — strict mocks fail if the deleter touches them itself
m.mbrSvc.EXPECT().ForceRemoveOrganizationMember(mock.Anything, orgID, userID, schema.UserPrincipal).Return(nil)

err := m.build().RemoveUsersFromOrg(context.Background(), orgID, []string{userID})
assert.NoError(t, err)
})

t.Run("propagates membership cascade failure", func(t *testing.T) {
m := newMocks(t)

m.mbrSvc.EXPECT().ForceRemoveOrganizationMember(mock.Anything, orgID, userID, schema.UserPrincipal).
Return(errors.New("cascade boom"))

err := m.build().RemoveUsersFromOrg(context.Background(), orgID, []string{userID})
assert.ErrorContains(t, err, "cascade boom")
})

t.Run("keeps going to the next user after one fails", func(t *testing.T) {
m := newMocks(t)

m.mbrSvc.EXPECT().ForceRemoveOrganizationMember(mock.Anything, orgID, userID, schema.UserPrincipal).
Return(errors.New("cascade boom"))
m.mbrSvc.EXPECT().ForceRemoveOrganizationMember(mock.Anything, orgID, "user-2", schema.UserPrincipal).Return(nil)

err := m.build().RemoveUsersFromOrg(context.Background(), orgID, []string{userID, "user-2"})
assert.ErrorContains(t, err, "cascade boom")
})
}

var (
orgOwner = role.Role{Name: schema.RoleOrganizationOwner, Title: "Owner"}
orgViewer = role.Role{Name: schema.RoleOrganizationViewer, Title: "Member"}
Expand Down Expand Up @@ -1891,7 +1854,7 @@ func TestDeleteUser(t *testing.T) {
expectNoBillingAccounts(m, solo.ID)
expectUserOrg(m, shared, []membership.Member{orgMember(userID, orgViewer), orgMember("user-2", orgOwner)}, userID, "user-2")
expectEmptyOrgDelete(m, &steps)
m.mbrSvc.EXPECT().ForceRemoveOrganizationMember(mock.Anything, shared.ID, userID, schema.UserPrincipal).
m.mbrSvc.EXPECT().RemoveOrganizationMemberOnDelete(mock.Anything, shared.ID, userID, schema.UserPrincipal).
Run(func(_ context.Context, orgID, _, _ string) { steps = append(steps, "leave:"+orgID) }).Return(nil)
m.mbrSvc.EXPECT().RemovePrincipalPolicies(mock.Anything, userID, schema.UserPrincipal).
Run(func(context.Context, string, string) { steps = append(steps, "policies") }).Return(nil)
Expand Down Expand Up @@ -1996,12 +1959,54 @@ func TestDeleteUser(t *testing.T) {
assert.Equal(t, []string{"policies", "pats", "user"}, steps)
})

t.Run("a user removed from an org since the check does not stop the delete", func(t *testing.T) {
m := newMocks(t)

expectUserOrgs(m, shared.ID)
expectUserOrg(m, shared, []membership.Member{orgMember(userID, orgViewer), orgMember("user-2", orgOwner)}, userID, "user-2")
m.mbrSvc.EXPECT().RemoveOrganizationMemberOnDelete(mock.Anything, shared.ID, userID, schema.UserPrincipal).
Return(membership.ErrNotMember)
m.mbrSvc.EXPECT().RemovePrincipalPolicies(mock.Anything, userID, schema.UserPrincipal).Return(nil)
m.patSvc.EXPECT().DeleteAllByUser(mock.Anything, userID).Return(nil)
m.usrSvc.EXPECT().Delete(mock.Anything, userID).Return(nil)

assert.NoError(t, m.build().DeleteUser(context.Background(), userID))
})

t.Run("an org the user leaves that was deleted since the check does not stop the delete", func(t *testing.T) {
m := newMocks(t)

expectUserOrgs(m, shared.ID)
expectUserOrg(m, shared, []membership.Member{orgMember(userID, orgViewer), orgMember("user-2", orgOwner)}, userID, "user-2")
m.mbrSvc.EXPECT().RemoveOrganizationMemberOnDelete(mock.Anything, shared.ID, userID, schema.UserPrincipal).
Return(organization.ErrNotExist)
m.mbrSvc.EXPECT().RemovePrincipalPolicies(mock.Anything, userID, schema.UserPrincipal).Return(nil)
m.patSvc.EXPECT().DeleteAllByUser(mock.Anything, userID).Return(nil)
m.usrSvc.EXPECT().Delete(mock.Anything, userID).Return(nil)

assert.NoError(t, m.build().DeleteUser(context.Background(), userID))
})

t.Run("an org that lost its other owner since the check refuses the delete with the blocker", func(t *testing.T) {
m := newMocks(t)

expectUserOrgs(m, shared.ID)
expectUserOrg(m, shared, []membership.Member{orgMember(userID, orgOwner), orgMember("user-2", orgOwner)}, userID, "user-2")
m.mbrSvc.EXPECT().RemoveOrganizationMemberOnDelete(mock.Anything, shared.ID, userID, schema.UserPrincipal).
Return(membership.ErrLastOwnerRole)

err := m.build().DeleteUser(context.Background(), userID)
var blocked *deleter.BlockedError
assert.ErrorAs(t, err, &blocked)
assert.Equal(t, &deleter.BlockedError{UserID: userID, Blockers: []deleter.Blocker{soleOwnerBlocker(shared)}}, blocked)
})

t.Run("an org removal failure stops the delete before the remaining policies", func(t *testing.T) {
m := newMocks(t)

expectUserOrgs(m, shared.ID)
expectUserOrg(m, shared, []membership.Member{orgMember(userID, orgViewer), orgMember("user-2", orgOwner)}, userID, "user-2")
m.mbrSvc.EXPECT().ForceRemoveOrganizationMember(mock.Anything, shared.ID, userID, schema.UserPrincipal).
m.mbrSvc.EXPECT().RemoveOrganizationMemberOnDelete(mock.Anything, shared.ID, userID, schema.UserPrincipal).
Return(errors.New("cascade boom"))

err := m.build().DeleteUser(context.Background(), userID)
Expand Down
Loading
Loading