forked from wrenn/wrenn
Fix cascading deletion gaps for user and team cleanup
- Add ON DELETE CASCADE to users_teams, oauth_providers, admin_permissions and ON DELETE SET NULL (with nullable columns) to team_api_keys.created_by, hosts.created_by, host_tokens.created_by so HardDeleteExpiredUsers no longer fails with FK violations - User account deletion now cascades to sole-owned teams via DeleteTeamInternal, preventing orphaned teams with live sandboxes after account removal - ListActiveSandboxesByTeam now includes hibernated sandboxes so their disk snapshots are cleaned up during team deletion - Team soft-delete now hard-deletes sandbox metric points, metric snapshots, API keys, and channels to prevent data accumulation on deleted teams - Extract deleteTeamCore() to deduplicate shared logic across DeleteTeam, AdminDeleteTeam, and DeleteTeamInternal - Fix ListAPIKeysByTeamWithCreator to use LEFT JOIN after created_by became nullable, and update handler to read pgtype.Text.String for creator_email
This commit is contained in:
@ -169,6 +169,12 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp
|
||||
return fmt.Errorf("forbidden: only the owner can delete a team")
|
||||
}
|
||||
|
||||
return s.deleteTeamCore(ctx, teamID)
|
||||
}
|
||||
|
||||
// deleteTeamCore contains the shared team deletion logic:
|
||||
// destroy active sandboxes, clean up templates, soft-delete the team.
|
||||
func (s *TeamService) deleteTeamCore(ctx context.Context, teamID pgtype.UUID) error {
|
||||
// Collect active sandboxes and stop them.
|
||||
sandboxes, err := s.DB.ListActiveSandboxesByTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
@ -202,6 +208,24 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp
|
||||
}
|
||||
}
|
||||
|
||||
// Delete sandbox metrics for this team.
|
||||
if err := s.DB.DeleteMetricPointsByTeam(ctx, teamID); err != nil {
|
||||
slog.Warn("team delete: failed to delete metric points", "team_id", id.FormatTeamID(teamID), "error", err)
|
||||
}
|
||||
if err := s.DB.DeleteMetricsSnapshotsByTeam(ctx, teamID); err != nil {
|
||||
slog.Warn("team delete: failed to delete metrics snapshots", "team_id", id.FormatTeamID(teamID), "error", err)
|
||||
}
|
||||
|
||||
// Delete all API keys for this team.
|
||||
if err := s.DB.DeleteAPIKeysByTeam(ctx, teamID); err != nil {
|
||||
slog.Warn("team delete: failed to delete API keys", "team_id", id.FormatTeamID(teamID), "error", err)
|
||||
}
|
||||
|
||||
// Delete all channels for this team.
|
||||
if err := s.DB.DeleteAllChannelsByTeam(ctx, teamID); err != nil {
|
||||
slog.Warn("team delete: failed to delete channels", "team_id", id.FormatTeamID(teamID), "error", err)
|
||||
}
|
||||
|
||||
// Clean up team-owned templates from all hosts in the background.
|
||||
go s.cleanupTeamTemplates(context.Background(), teamID)
|
||||
|
||||
@ -497,6 +521,13 @@ func (s *TeamService) AdminListTeams(ctx context.Context, limit, offset int32) (
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// DeleteTeamInternal soft-deletes a team and destroys all its active sandboxes.
|
||||
// Used for system-initiated deletions (e.g. cascading from user account deletion)
|
||||
// where no caller role check is needed.
|
||||
func (s *TeamService) DeleteTeamInternal(ctx context.Context, teamID pgtype.UUID) error {
|
||||
return s.deleteTeamCore(ctx, teamID)
|
||||
}
|
||||
|
||||
// AdminDeleteTeam soft-deletes a team and destroys all its active sandboxes.
|
||||
// Unlike DeleteTeam, this does not require the caller to be the team owner —
|
||||
// it is admin-only (caller must verify admin status).
|
||||
@ -509,41 +540,5 @@ func (s *TeamService) AdminDeleteTeam(ctx context.Context, teamID pgtype.UUID) e
|
||||
return fmt.Errorf("team not found")
|
||||
}
|
||||
|
||||
// Destroy active sandboxes (same logic as DeleteTeam).
|
||||
sandboxes, err := s.DB.ListActiveSandboxesByTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list active sandboxes: %w", err)
|
||||
}
|
||||
|
||||
var stopIDs []pgtype.UUID
|
||||
for _, sb := range sandboxes {
|
||||
host, hostErr := s.DB.GetHost(ctx, sb.HostID)
|
||||
if hostErr == nil {
|
||||
agent, agentErr := s.HostPool.GetForHost(host)
|
||||
if agentErr == nil {
|
||||
if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{
|
||||
SandboxId: id.FormatSandboxID(sb.ID),
|
||||
})); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
|
||||
slog.Warn("admin team delete: failed to destroy sandbox", "sandbox_id", id.FormatSandboxID(sb.ID), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
stopIDs = append(stopIDs, sb.ID)
|
||||
}
|
||||
|
||||
if len(stopIDs) > 0 {
|
||||
if err := s.DB.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{
|
||||
Column1: stopIDs,
|
||||
Status: "stopped",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("update sandbox statuses: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
go s.cleanupTeamTemplates(context.Background(), teamID)
|
||||
|
||||
if err := s.DB.SoftDeleteTeam(ctx, teamID); err != nil {
|
||||
return fmt.Errorf("soft delete team: %w", err)
|
||||
}
|
||||
return nil
|
||||
return s.deleteTeamCore(ctx, teamID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user