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:
72
db/migrations/20260415221116_cascade_user_delete.sql
Normal file
72
db/migrations/20260415221116_cascade_user_delete.sql
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- users_teams: remove membership when user is deleted
|
||||||
|
ALTER TABLE users_teams DROP CONSTRAINT users_teams_user_id_fkey;
|
||||||
|
ALTER TABLE users_teams ADD CONSTRAINT users_teams_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- oauth_providers: remove auth links when user is deleted
|
||||||
|
ALTER TABLE oauth_providers DROP CONSTRAINT oauth_providers_user_id_fkey;
|
||||||
|
ALTER TABLE oauth_providers ADD CONSTRAINT oauth_providers_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- admin_permissions: remove permissions when user is deleted
|
||||||
|
ALTER TABLE admin_permissions DROP CONSTRAINT admin_permissions_user_id_fkey;
|
||||||
|
ALTER TABLE admin_permissions ADD CONSTRAINT admin_permissions_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- team_api_keys.created_by: make nullable, SET NULL on user delete
|
||||||
|
ALTER TABLE team_api_keys ALTER COLUMN created_by DROP NOT NULL;
|
||||||
|
ALTER TABLE team_api_keys DROP CONSTRAINT team_api_keys_created_by_fkey;
|
||||||
|
ALTER TABLE team_api_keys ADD CONSTRAINT team_api_keys_created_by_fkey
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- hosts.created_by: make nullable, SET NULL on user delete
|
||||||
|
ALTER TABLE hosts ALTER COLUMN created_by DROP NOT NULL;
|
||||||
|
ALTER TABLE hosts DROP CONSTRAINT hosts_created_by_fkey;
|
||||||
|
ALTER TABLE hosts ADD CONSTRAINT hosts_created_by_fkey
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- host_tokens.created_by: make nullable, SET NULL on user delete
|
||||||
|
ALTER TABLE host_tokens ALTER COLUMN created_by DROP NOT NULL;
|
||||||
|
ALTER TABLE host_tokens DROP CONSTRAINT host_tokens_created_by_fkey;
|
||||||
|
ALTER TABLE host_tokens ADD CONSTRAINT host_tokens_created_by_fkey
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
-- Revert host_tokens.created_by
|
||||||
|
ALTER TABLE host_tokens DROP CONSTRAINT host_tokens_created_by_fkey;
|
||||||
|
UPDATE host_tokens SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
|
||||||
|
ALTER TABLE host_tokens ALTER COLUMN created_by SET NOT NULL;
|
||||||
|
ALTER TABLE host_tokens ADD CONSTRAINT host_tokens_created_by_fkey
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||||
|
|
||||||
|
-- Revert hosts.created_by
|
||||||
|
ALTER TABLE hosts DROP CONSTRAINT hosts_created_by_fkey;
|
||||||
|
UPDATE hosts SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
|
||||||
|
ALTER TABLE hosts ALTER COLUMN created_by SET NOT NULL;
|
||||||
|
ALTER TABLE hosts ADD CONSTRAINT hosts_created_by_fkey
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||||
|
|
||||||
|
-- Revert team_api_keys.created_by
|
||||||
|
ALTER TABLE team_api_keys DROP CONSTRAINT team_api_keys_created_by_fkey;
|
||||||
|
UPDATE team_api_keys SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
|
||||||
|
ALTER TABLE team_api_keys ALTER COLUMN created_by SET NOT NULL;
|
||||||
|
ALTER TABLE team_api_keys ADD CONSTRAINT team_api_keys_created_by_fkey
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||||
|
|
||||||
|
-- Revert admin_permissions
|
||||||
|
ALTER TABLE admin_permissions DROP CONSTRAINT admin_permissions_user_id_fkey;
|
||||||
|
ALTER TABLE admin_permissions ADD CONSTRAINT admin_permissions_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id);
|
||||||
|
|
||||||
|
-- Revert oauth_providers
|
||||||
|
ALTER TABLE oauth_providers DROP CONSTRAINT oauth_providers_user_id_fkey;
|
||||||
|
ALTER TABLE oauth_providers ADD CONSTRAINT oauth_providers_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id);
|
||||||
|
|
||||||
|
-- Revert users_teams
|
||||||
|
ALTER TABLE users_teams DROP CONSTRAINT users_teams_user_id_fkey;
|
||||||
|
ALTER TABLE users_teams ADD CONSTRAINT users_teams_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id);
|
||||||
@ -13,7 +13,7 @@ SELECT * FROM team_api_keys WHERE team_id = $1 ORDER BY created_at DESC;
|
|||||||
SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used,
|
SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used,
|
||||||
u.email AS creator_email
|
u.email AS creator_email
|
||||||
FROM team_api_keys k
|
FROM team_api_keys k
|
||||||
JOIN users u ON u.id = k.created_by
|
LEFT JOIN users u ON u.id = k.created_by
|
||||||
WHERE k.team_id = $1
|
WHERE k.team_id = $1
|
||||||
ORDER BY k.created_at DESC;
|
ORDER BY k.created_at DESC;
|
||||||
|
|
||||||
@ -22,3 +22,6 @@ DELETE FROM team_api_keys WHERE id = $1 AND team_id = $2;
|
|||||||
|
|
||||||
-- name: UpdateAPIKeyLastUsed :exec
|
-- name: UpdateAPIKeyLastUsed :exec
|
||||||
UPDATE team_api_keys SET last_used = NOW() WHERE id = $1;
|
UPDATE team_api_keys SET last_used = NOW() WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteAPIKeysByTeam :exec
|
||||||
|
DELETE FROM team_api_keys WHERE team_id = $1;
|
||||||
|
|||||||
@ -22,6 +22,9 @@ RETURNING *;
|
|||||||
-- name: DeleteChannelByTeam :exec
|
-- name: DeleteChannelByTeam :exec
|
||||||
DELETE FROM channels WHERE id = $1 AND team_id = $2;
|
DELETE FROM channels WHERE id = $1 AND team_id = $2;
|
||||||
|
|
||||||
|
-- name: DeleteAllChannelsByTeam :exec
|
||||||
|
DELETE FROM channels WHERE team_id = $1;
|
||||||
|
|
||||||
-- name: ListChannelsForEvent :many
|
-- name: ListChannelsForEvent :many
|
||||||
SELECT * FROM channels
|
SELECT * FROM channels
|
||||||
WHERE team_id = $1
|
WHERE team_id = $1
|
||||||
|
|||||||
@ -51,6 +51,13 @@ WHERE sandbox_id = $1 AND tier = $2;
|
|||||||
DELETE FROM sandbox_metric_points
|
DELETE FROM sandbox_metric_points
|
||||||
WHERE ts < EXTRACT(EPOCH FROM NOW() - INTERVAL '30 days')::BIGINT;
|
WHERE ts < EXTRACT(EPOCH FROM NOW() - INTERVAL '30 days')::BIGINT;
|
||||||
|
|
||||||
|
-- name: DeleteMetricsSnapshotsByTeam :exec
|
||||||
|
DELETE FROM sandbox_metrics_snapshots WHERE team_id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteMetricPointsByTeam :exec
|
||||||
|
DELETE FROM sandbox_metric_points
|
||||||
|
WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE team_id = $1);
|
||||||
|
|
||||||
-- name: SampleSandboxMetrics :many
|
-- name: SampleSandboxMetrics :many
|
||||||
-- Aggregates per-team resource usage from the live sandboxes table.
|
-- Aggregates per-team resource usage from the live sandboxes table.
|
||||||
-- Groups by all teams that have any sandbox row (including stopped) so that
|
-- Groups by all teams that have any sandbox row (including stopped) so that
|
||||||
|
|||||||
@ -62,7 +62,7 @@ WHERE id = ANY($1::uuid[]);
|
|||||||
|
|
||||||
-- name: ListActiveSandboxesByTeam :many
|
-- name: ListActiveSandboxesByTeam :many
|
||||||
SELECT * FROM sandboxes
|
SELECT * FROM sandboxes
|
||||||
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting')
|
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting', 'hibernated')
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: MarkSandboxesMissingByHost :exec
|
-- name: MarkSandboxesMissingByHost :exec
|
||||||
|
|||||||
@ -74,6 +74,18 @@ WHERE t.id != '00000000-0000-0000-0000-000000000000'
|
|||||||
ORDER BY t.deleted_at ASC NULLS FIRST, t.created_at DESC
|
ORDER BY t.deleted_at ASC NULLS FIRST, t.created_at DESC
|
||||||
LIMIT $1 OFFSET $2;
|
LIMIT $1 OFFSET $2;
|
||||||
|
|
||||||
|
-- name: ListSoleOwnedTeams :many
|
||||||
|
-- Returns teams where the user is the owner and no other members exist.
|
||||||
|
SELECT t.id FROM teams t
|
||||||
|
JOIN users_teams ut ON ut.team_id = t.id
|
||||||
|
WHERE ut.user_id = $1
|
||||||
|
AND ut.role = 'owner'
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM users_teams ut2
|
||||||
|
WHERE ut2.team_id = t.id AND ut2.user_id <> $1
|
||||||
|
);
|
||||||
|
|
||||||
-- name: CountTeamsAdmin :one
|
-- name: CountTeamsAdmin :one
|
||||||
SELECT COUNT(*)::int AS total
|
SELECT COUNT(*)::int AS total
|
||||||
FROM teams
|
FROM teams
|
||||||
|
|||||||
@ -63,7 +63,7 @@ func apiKeyWithCreatorToResponse(k db.ListAPIKeysByTeamWithCreatorRow) apiKeyRes
|
|||||||
Name: k.Name,
|
Name: k.Name,
|
||||||
KeyPrefix: k.KeyPrefix,
|
KeyPrefix: k.KeyPrefix,
|
||||||
CreatedBy: id.FormatUserID(k.CreatedBy),
|
CreatedBy: id.FormatUserID(k.CreatedBy),
|
||||||
CreatorEmail: k.CreatorEmail,
|
CreatorEmail: k.CreatorEmail.String,
|
||||||
}
|
}
|
||||||
if k.CreatedAt.Valid {
|
if k.CreatedAt.Valid {
|
||||||
resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339)
|
resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339)
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import (
|
|||||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
|
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
|
||||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/pkg/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -37,6 +38,7 @@ type meHandler struct {
|
|||||||
mailer email.Mailer
|
mailer email.Mailer
|
||||||
oauthRegistry *oauth.Registry
|
oauthRegistry *oauth.Registry
|
||||||
redirectURL string
|
redirectURL string
|
||||||
|
teamSvc *service.TeamService
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMeHandler(
|
func newMeHandler(
|
||||||
@ -47,6 +49,7 @@ func newMeHandler(
|
|||||||
mailer email.Mailer,
|
mailer email.Mailer,
|
||||||
registry *oauth.Registry,
|
registry *oauth.Registry,
|
||||||
redirectURL string,
|
redirectURL string,
|
||||||
|
teamSvc *service.TeamService,
|
||||||
) *meHandler {
|
) *meHandler {
|
||||||
return &meHandler{
|
return &meHandler{
|
||||||
db: db,
|
db: db,
|
||||||
@ -56,6 +59,7 @@ func newMeHandler(
|
|||||||
mailer: mailer,
|
mailer: mailer,
|
||||||
oauthRegistry: registry,
|
oauthRegistry: registry,
|
||||||
redirectURL: strings.TrimRight(redirectURL, "/"),
|
redirectURL: strings.TrimRight(redirectURL, "/"),
|
||||||
|
teamSvc: teamSvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -507,6 +511,19 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete all teams the user solely owns (no other members).
|
||||||
|
soleTeams, err := h.db.ListSoleOwnedTeams(ctx, ac.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to list owned teams")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, teamID := range soleTeams {
|
||||||
|
if err := h.teamSvc.DeleteTeamInternal(ctx, teamID); err != nil {
|
||||||
|
slog.Warn("account delete: failed to delete sole-owned team",
|
||||||
|
"team_id", id.FormatTeamID(teamID), "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.db.SoftDeleteUser(ctx, ac.UserID); err != nil {
|
if err := h.db.SoftDeleteUser(ctx, ac.UserID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete account")
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete account")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -84,7 +84,7 @@ func New(
|
|||||||
ptyH := newPtyHandler(queries, pool)
|
ptyH := newPtyHandler(queries, pool)
|
||||||
processH := newProcessHandler(queries, pool)
|
processH := newProcessHandler(queries, pool)
|
||||||
adminCapsules := newAdminCapsuleHandler(sandboxSvc, queries, pool, al)
|
adminCapsules := newAdminCapsuleHandler(sandboxSvc, queries, pool, al)
|
||||||
meH := newMeHandler(queries, pgPool, rdb, jwtSecret, mailer, oauthRegistry, oauthRedirectURL)
|
meH := newMeHandler(queries, pgPool, rdb, jwtSecret, mailer, oauthRegistry, oauthRedirectURL, teamSvc)
|
||||||
|
|
||||||
// OpenAPI spec and docs.
|
// OpenAPI spec and docs.
|
||||||
r.Get("/openapi.yaml", serveOpenAPI)
|
r.Get("/openapi.yaml", serveOpenAPI)
|
||||||
|
|||||||
@ -25,6 +25,15 @@ func (q *Queries) DeleteAPIKey(ctx context.Context, arg DeleteAPIKeyParams) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteAPIKeysByTeam = `-- name: DeleteAPIKeysByTeam :exec
|
||||||
|
DELETE FROM team_api_keys WHERE team_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteAPIKeysByTeam(ctx context.Context, teamID pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteAPIKeysByTeam, teamID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const getAPIKeyByHash = `-- name: GetAPIKeyByHash :one
|
const getAPIKeyByHash = `-- name: GetAPIKeyByHash :one
|
||||||
SELECT id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used FROM team_api_keys WHERE key_hash = $1
|
SELECT id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used FROM team_api_keys WHERE key_hash = $1
|
||||||
`
|
`
|
||||||
@ -120,7 +129,7 @@ const listAPIKeysByTeamWithCreator = `-- name: ListAPIKeysByTeamWithCreator :man
|
|||||||
SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used,
|
SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used,
|
||||||
u.email AS creator_email
|
u.email AS creator_email
|
||||||
FROM team_api_keys k
|
FROM team_api_keys k
|
||||||
JOIN users u ON u.id = k.created_by
|
LEFT JOIN users u ON u.id = k.created_by
|
||||||
WHERE k.team_id = $1
|
WHERE k.team_id = $1
|
||||||
ORDER BY k.created_at DESC
|
ORDER BY k.created_at DESC
|
||||||
`
|
`
|
||||||
@ -134,7 +143,7 @@ type ListAPIKeysByTeamWithCreatorRow struct {
|
|||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
LastUsed pgtype.Timestamptz `json:"last_used"`
|
LastUsed pgtype.Timestamptz `json:"last_used"`
|
||||||
CreatorEmail string `json:"creator_email"`
|
CreatorEmail pgtype.Text `json:"creator_email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListAPIKeysByTeamWithCreator(ctx context.Context, teamID pgtype.UUID) ([]ListAPIKeysByTeamWithCreatorRow, error) {
|
func (q *Queries) ListAPIKeysByTeamWithCreator(ctx context.Context, teamID pgtype.UUID) ([]ListAPIKeysByTeamWithCreatorRow, error) {
|
||||||
|
|||||||
@ -11,6 +11,15 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const deleteAllChannelsByTeam = `-- name: DeleteAllChannelsByTeam :exec
|
||||||
|
DELETE FROM channels WHERE team_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteAllChannelsByTeam(ctx context.Context, teamID pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteAllChannelsByTeam, teamID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const deleteChannelByTeam = `-- name: DeleteChannelByTeam :exec
|
const deleteChannelByTeam = `-- name: DeleteChannelByTeam :exec
|
||||||
DELETE FROM channels WHERE id = $1 AND team_id = $2
|
DELETE FROM channels WHERE id = $1 AND team_id = $2
|
||||||
`
|
`
|
||||||
|
|||||||
@ -11,6 +11,25 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const deleteMetricPointsByTeam = `-- name: DeleteMetricPointsByTeam :exec
|
||||||
|
DELETE FROM sandbox_metric_points
|
||||||
|
WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE team_id = $1)
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteMetricPointsByTeam(ctx context.Context, teamID pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteMetricPointsByTeam, teamID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMetricsSnapshotsByTeam = `-- name: DeleteMetricsSnapshotsByTeam :exec
|
||||||
|
DELETE FROM sandbox_metrics_snapshots WHERE team_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteMetricsSnapshotsByTeam(ctx context.Context, teamID pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteMetricsSnapshotsByTeam, teamID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const deleteSandboxMetricPoints = `-- name: DeleteSandboxMetricPoints :exec
|
const deleteSandboxMetricPoints = `-- name: DeleteSandboxMetricPoints :exec
|
||||||
DELETE FROM sandbox_metric_points
|
DELETE FROM sandbox_metric_points
|
||||||
WHERE sandbox_id = $1
|
WHERE sandbox_id = $1
|
||||||
|
|||||||
@ -190,7 +190,7 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
|
|||||||
|
|
||||||
const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many
|
const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many
|
||||||
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata FROM sandboxes
|
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata FROM sandboxes
|
||||||
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting')
|
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting', 'hibernated')
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@ -284,6 +284,39 @@ func (q *Queries) InsertTeamMember(ctx context.Context, arg InsertTeamMemberPara
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listSoleOwnedTeams = `-- name: ListSoleOwnedTeams :many
|
||||||
|
SELECT t.id FROM teams t
|
||||||
|
JOIN users_teams ut ON ut.team_id = t.id
|
||||||
|
WHERE ut.user_id = $1
|
||||||
|
AND ut.role = 'owner'
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM users_teams ut2
|
||||||
|
WHERE ut2.team_id = t.id AND ut2.user_id <> $1
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
// Returns teams where the user is the owner and no other members exist.
|
||||||
|
func (q *Queries) ListSoleOwnedTeams(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listSoleOwnedTeams, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []pgtype.UUID
|
||||||
|
for rows.Next() {
|
||||||
|
var id pgtype.UUID
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listTeamsAdmin = `-- name: ListTeamsAdmin :many
|
const listTeamsAdmin = `-- name: ListTeamsAdmin :many
|
||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
|
|||||||
@ -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 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.
|
// Collect active sandboxes and stop them.
|
||||||
sandboxes, err := s.DB.ListActiveSandboxesByTeam(ctx, teamID)
|
sandboxes, err := s.DB.ListActiveSandboxesByTeam(ctx, teamID)
|
||||||
if err != nil {
|
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.
|
// Clean up team-owned templates from all hosts in the background.
|
||||||
go s.cleanupTeamTemplates(context.Background(), teamID)
|
go s.cleanupTeamTemplates(context.Background(), teamID)
|
||||||
|
|
||||||
@ -497,6 +521,13 @@ func (s *TeamService) AdminListTeams(ctx context.Context, limit, offset int32) (
|
|||||||
return rows, total, nil
|
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.
|
// AdminDeleteTeam soft-deletes a team and destroys all its active sandboxes.
|
||||||
// Unlike DeleteTeam, this does not require the caller to be the team owner —
|
// Unlike DeleteTeam, this does not require the caller to be the team owner —
|
||||||
// it is admin-only (caller must verify admin status).
|
// 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")
|
return fmt.Errorf("team not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destroy active sandboxes (same logic as DeleteTeam).
|
return s.deleteTeamCore(ctx, teamID)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user