Files
worker/app/models/deletion.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

583 строки
19 KiB
Go

package models
import (
"errors"
"log"
"time"
"gorm.io/gorm"
)
// OnUserCacheInvalidate is called when a user is mutated in a way that
// invalidates the cached representation in auth/cache.go. It is wired up
// from the auth package during init() to avoid an import cycle.
var OnUserCacheInvalidate func(userID int64)
// DeletionGracePeriod is the time between a user requesting account deletion
// and the scheduled hard-delete. During this period the user can cancel the
// request and monitoring is paused for all of the user's accounts.
const DeletionGracePeriod = 7 * 24 * time.Hour
// RequestUserDeletion sets the user's DeletionRequestedAt to now,
// starting the 7-day grace period.
func RequestUserDeletion(user *User) error {
if user == nil {
return errors.New("nil user")
}
if user.DeletionPending() {
return nil
}
now := time.Now()
user.DeletionRequestedAt = &now
if err := DB().Save(user).Error; err != nil {
return err
}
if OnUserCacheInvalidate != nil {
OnUserCacheInvalidate(user.ID)
}
log.Printf("user %d requested account deletion (grace until %s)", user.ID, now.Add(DeletionGracePeriod))
return nil
}
// CancelUserDeletion clears the user's DeletionRequestedAt field,
// canceling the pending account deletion.
func CancelUserDeletion(user *User) error {
if user == nil {
return errors.New("nil user")
}
if !user.DeletionPending() {
return nil
}
user.DeletionRequestedAt = nil
if err := DB().Save(user).Error; err != nil {
return err
}
if OnUserCacheInvalidate != nil {
OnUserCacheInvalidate(user.ID)
}
log.Printf("user %d canceled account deletion", user.ID)
return nil
}
// HardDeleteAccount removes an account and all of its data (monitors, checks,
// events, messages, contacts, groups, notifications, etc.) in a single
// transaction. Accesses and invites pointing at the account are also cleaned
// up. Users themselves are kept (they may belong to other accounts). This is
// the admin "purge" path used to remove spam/error accounts immediately.
func HardDeleteAccount(accountID int64) error {
return DB().Transaction(func(tx *gorm.DB) error {
// Remove accesses pointing at this account first (FK constraint).
if err := tx.Where("account_id = ?", accountID).Delete(&Access{}).Error; err != nil {
return err
}
// Remove invites for this account.
if err := tx.Where("account_id = ?", accountID).Delete(&Invite{}).Error; err != nil {
return err
}
// Delete all account data and the account row itself.
return deleteAccountData(tx, accountID)
})
}
// HardDeleteUser removes the user and all of their owned data: accesses,
// contacts, accounts, monitors, checks, events and messages. The deletion
// is wrapped in a transaction to make sure partial failures don't leave
// the database in a broken state.
func HardDeleteUser(userID int64) error {
return DB().Transaction(func(tx *gorm.DB) error {
user := User{}
if err := tx.First(&user, userID).Error; err != nil {
return err
}
// 1) Find all accounts the user has any access to.
accesses := make([]Access, 0)
if err := tx.Where("user_id = ?", userID).Find(&accesses).Error; err != nil {
return err
}
accountIDs := make([]int64, 0, len(accesses))
for i := range accesses {
accountIDs = append(accountIDs, accesses[i].AccountID)
}
// 2) Find all contacts that belong to those accounts or directly to the user.
if err := tx.Where("user_id = ?", userID).Delete(&Contact{}).Error; err != nil {
return err
}
// 3) For each account: delete the related data, then the account itself.
for _, accountID := range accountIDs {
if err := deleteAccountData(tx, accountID); err != nil {
return err
}
}
// 4) Remove accesses.
if err := tx.Where("user_id = ?", userID).Delete(&Access{}).Error; err != nil {
return err
}
// 5) Remove invites issued by or for this user.
if err := tx.Exec("DELETE FROM invites WHERE invitee_id = ? OR account_id IN (?)",
userID, accountIDs).Error; err != nil {
return err
}
// 6) Remove auth identities (password, social).
if err := tx.Exec("DELETE FROM identities WHERE user_id = ?", userID).Error; err != nil {
return err
}
// 7) Remove api keys owned by the user.
if err := tx.Where("user_id = ?", userID).Delete(&ApiKey{}).Error; err != nil {
return err
}
// 8) Remove sessions.
if err := tx.Exec("DELETE FROM remember_tokens WHERE user_id = ?", userID).Error; err != nil {
return err
}
// 9) Finally remove the user row.
if err := tx.Delete(&user).Error; err != nil {
return err
}
log.Printf("user %d hard-deleted (cascade accounts=%v)", userID, accountIDs)
return nil
})
}
// deleteAccountData removes everything associated with a single account:
// contacts, monitors, checks, events, messages, notifications, groups and
// the account itself. The user accesses are removed separately.
func deleteAccountData(tx *gorm.DB, accountID int64) error {
// All contacts that reference this account (both account-scoped ones
// with user_id IS NULL and per-user ones created in
// CreateAccountForUser that set both account_id and user_id).
// User-only contacts (account_id IS NULL) are independent of the
// account and survive the deletion. contacts.account_id has a FK
// to accounts(id) without ON DELETE CASCADE, so all referencing
// rows must be removed before the account row goes away.
contactIDs := make([]int64, 0)
if err := tx.Model(&Contact{}).Where("account_id = ?", accountID).Pluck("id", &contactIDs).Error; err != nil {
return err
}
notificationIDs := make([]int64, 0)
if err := tx.Model(&Notification{}).Where("account_id = ?", accountID).Pluck("id", &notificationIDs).Error; err != nil {
return err
}
if len(contactIDs) > 0 {
if err := tx.Exec("DELETE FROM notification_contacts WHERE contact_id IN (?)", contactIDs).Error; err != nil {
return err
}
}
if len(notificationIDs) > 0 {
if err := tx.Exec("DELETE FROM notification_contacts WHERE notification_id IN (?)", notificationIDs).Error; err != nil {
return err
}
}
messageQuery := tx.Model(&Message{})
switch {
case len(contactIDs) > 0 && len(notificationIDs) > 0:
messageQuery = messageQuery.Where("contact_id IN (?) OR notification_id IN (?)", contactIDs, notificationIDs)
case len(contactIDs) > 0:
messageQuery = messageQuery.Where("contact_id IN (?)", contactIDs)
case len(notificationIDs) > 0:
messageQuery = messageQuery.Where("notification_id IN (?)", notificationIDs)
default:
messageQuery = nil
}
if messageQuery != nil {
messageIDs := make([]int64, 0)
if err := messageQuery.Pluck("id", &messageIDs).Error; err != nil {
return err
}
if len(messageIDs) > 0 {
if err := tx.Exec("DELETE FROM event_messages WHERE message_id IN (?)", messageIDs).Error; err != nil {
return err
}
if err := tx.Where("id IN (?)", messageIDs).Delete(&Message{}).Error; err != nil {
return err
}
}
}
if err := tx.Where("account_id = ?", accountID).Delete(&Contact{}).Error; err != nil {
return err
}
// Monitors (and their checks/events/messages via cascade below).
monitors := make([]Monitor, 0)
if err := tx.Joins("JOIN groups ON monitors.group_id = groups.id").
Where("groups.account_id = ?", accountID).Find(&monitors).Error; err != nil {
return err
}
monitorIDs := make([]int64, 0, len(monitors))
for i := range monitors {
monitorIDs = append(monitorIDs, monitors[i].ID)
}
if len(monitorIDs) > 0 {
// Checks
checks := make([]Check, 0)
if err := tx.Where("monitor_id IN (?)", monitorIDs).Find(&checks).Error; err != nil {
return err
}
checkIDs := make([]int64, 0, len(checks))
for i := range checks {
checkIDs = append(checkIDs, checks[i].ID)
}
// Events
events := make([]Event, 0)
if err := tx.Where("monitor_id IN (?)", monitorIDs).Find(&events).Error; err != nil {
return err
}
eventIDs := make([]int64, 0, len(events))
for i := range events {
eventIDs = append(eventIDs, events[i].ID)
}
// Join tables first to avoid FK violations
if len(checkIDs) > 0 {
if err := tx.Exec("DELETE FROM event_checks WHERE check_id IN (?)", checkIDs).Error; err != nil {
return err
}
// check_region_results.check_id has a FK to checks(id) without
// ON DELETE CASCADE, so it must be purged before checks go away.
if err := tx.Exec("DELETE FROM check_region_results WHERE check_id IN (?)", checkIDs).Error; err != nil {
return err
}
}
if len(eventIDs) > 0 {
if err := tx.Exec("DELETE FROM event_messages WHERE event_id IN (?)", eventIDs).Error; err != nil {
return err
}
}
if len(checkIDs) > 0 {
if err := tx.Where("id IN (?)", checkIDs).Delete(&Check{}).Error; err != nil {
return err
}
}
if len(eventIDs) > 0 {
if err := tx.Where("id IN (?)", eventIDs).Delete(&Event{}).Error; err != nil {
return err
}
}
// DNS records
if err := tx.Where("monitor_id IN (?)", monitorIDs).Delete(&DNSRecord{}).Error; err != nil {
return err
}
// Monitors themselves
if err := tx.Where("id IN (?)", monitorIDs).Delete(&Monitor{}).Error; err != nil {
return err
}
}
// Notification <-> group links
if err := tx.Exec(
"DELETE FROM notification_groups WHERE group_id IN (SELECT id FROM groups WHERE account_id = ?)",
accountID,
).Error; err != nil {
return err
}
// Notifications
if err := tx.Where("account_id = ?", accountID).Delete(&Notification{}).Error; err != nil {
return err
}
// LLMs scoped to this account. worker_llms.llm_id has a FK to llms(id)
// without ON DELETE CASCADE, so the join rows must go first.
if err := tx.Exec(
"DELETE FROM worker_llms WHERE llm_id IN (SELECT id FROM llms WHERE account_id = ?)",
accountID,
).Error; err != nil {
return err
}
if err := tx.Where("account_id = ?", accountID).Delete(&LLM{}).Error; err != nil {
return err
}
// Groups
if err := tx.Where("account_id = ?", accountID).Delete(&Group{}).Error; err != nil {
return err
}
// Inventory entities (docs/plans/inventory-management.md §6). All
// four tables hold account_id FKs to accounts(id) without ON
// DELETE CASCADE, so we wipe them in dependency order: deployments
// and domains first (both FK into sites and servers), then
// server_ips, then sites, then servers. Anything the account does
// not own is left alone (e.g. shared infra servers are filtered
// by account_id and survive).
if err := tx.Where("account_id = ?", accountID).Delete(&Deployment{}).Error; err != nil {
return err
}
if err := tx.Where("account_id = ?", accountID).Delete(&Domain{}).Error; err != nil {
return err
}
if err := tx.Exec(
"DELETE FROM server_ips WHERE server_id IN (SELECT id FROM servers WHERE account_id = ?)",
accountID,
).Error; err != nil {
return err
}
if err := tx.Where("account_id = ?", accountID).Delete(&Site{}).Error; err != nil {
return err
}
if err := tx.Where("account_id = ?", accountID).Delete(&Server{}).Error; err != nil {
return err
}
// Private workers scoped to this account (NULL account_id workers
// are platform-operated and survive account deletion). The
// worker_llms and check_region_results joins both FK to
// worker_nodes(id) without ON DELETE CASCADE, so the join rows
// must be cleared before the worker rows go away.
if err := tx.Exec(
"DELETE FROM worker_llms WHERE worker_node_id IN (SELECT id FROM worker_nodes WHERE account_id = ?)",
accountID,
).Error; err != nil {
return err
}
if err := tx.Exec(
"DELETE FROM check_region_results WHERE worker_node_id IN (SELECT id FROM worker_nodes WHERE account_id = ?)",
accountID,
).Error; err != nil {
return err
}
if err := tx.Where("account_id = ?", accountID).Delete(&WorkerNode{}).Error; err != nil {
return err
}
// The account itself
return tx.Delete(&Account{}, accountID).Error
}
// ProcessPendingDeletions hard-deletes users whose 7-day grace period has
// elapsed. Designed to be called from a periodic scheduler.
func ProcessPendingDeletions() (int, error) {
cutoff := time.Now().Add(-DeletionGracePeriod)
users := make([]User, 0)
if err := DB().Where("deletion_requested_at IS NOT NULL AND deletion_requested_at < ?", cutoff).
Find(&users).Error; err != nil {
return 0, err
}
deleted := 0
for i := range users {
if err := HardDeleteUser(users[i].ID); err != nil {
log.Printf("ProcessPendingDeletions: failed to delete user %d: %v", users[i].ID, err)
continue
}
deleted++
}
if deleted > 0 {
log.Printf("ProcessPendingDeletions: hard-deleted %d user(s)", deleted)
}
return deleted, nil
}
// StaleAccountInactivity is the minimum inactivity window before a stale
// account becomes eligible for admin cleanup. Picked at 3 months per the
// /admin/accounts "Удалить старые" button.
const StaleAccountInactivity = 90 * 24 * time.Hour
// StaleAccountCandidate describes an account that matched the
// admin-cleanup eligibility filter but has not yet been deleted. The
// snapshot is what the frontend shows in the confirmation dialog.
type StaleAccountCandidate struct {
AccountID int64 `json:"account_id"`
AccountName string `json:"account_name"`
UserID int64 `json:"user_id"`
UserEmail *string `json:"user_email"`
LastActiveAt *time.Time `json:"last_active_at"`
}
// FindStaleAccounts returns the accounts that are eligible for the
// admin "Удалить старые" cleanup:
//
// - the account has zero monitors configured (via group.account_id)
// - every user with access to the account has exactly one account
// membership total (so removing the account also orphans them)
// - every such user's last_active_at is older than
// StaleAccountInactivity (3 months). Users who have never logged in
// (last_active_at IS NULL) are also eligible.
func FindStaleAccounts() ([]StaleAccountCandidate, error) {
cutoff := time.Now().Add(-StaleAccountInactivity)
// Step 1: account IDs that have zero monitors.
accountsWithMonitors := make([]int64, 0)
if err := DB().
Table("monitors").
Select("DISTINCT groups.account_id").
Joins("JOIN groups ON groups.id = monitors.group_id").
Scan(&accountsWithMonitors).Error; err != nil {
return nil, err
}
accounts := make([]Account, 0)
q := DB().Order("id ASC")
if len(accountsWithMonitors) > 0 {
q = q.Where("id NOT IN (?)", accountsWithMonitors)
}
if err := q.Find(&accounts).Error; err != nil {
return nil, err
}
out := make([]StaleAccountCandidate, 0, len(accounts))
for i := range accounts {
acc := accounts[i]
// Step 2: every user with access to this account must have
// exactly one account membership total.
type userAccessCount struct {
UserID int64
Cnt int
}
counts := make([]userAccessCount, 0)
err := DB().
Table("accesses AS a1").
Select("a1.user_id AS user_id, (SELECT COUNT(*) FROM accesses AS a2 WHERE a2.user_id = a1.user_id) AS cnt").
Where("a1.account_id = ? AND a1.user_id IS NOT NULL", acc.ID).
Group("a1.user_id").
Scan(&counts).Error
if err != nil {
return nil, err
}
if len(counts) == 0 {
// An account with no user accesses is a config bug
// (the owner access should always exist). Skip.
continue
}
allSingle := true
for _, c := range counts {
if c.Cnt != 1 {
allSingle = false
break
}
}
if !allSingle {
continue
}
// Step 3: every such user must be inactive beyond the cutoff.
userIDs := make([]int64, 0, len(counts))
for _, c := range counts {
userIDs = append(userIDs, c.UserID)
}
users := make([]User, 0)
if err := DB().Where("id IN (?)", userIDs).Find(&users).Error; err != nil {
return nil, err
}
allStale := true
for j := range users {
u := users[j]
if u.LastActiveAt != nil && u.LastActiveAt.After(cutoff) {
allStale = false
break
}
}
if !allStale {
continue
}
// All gates passed — emit one candidate per user so the UI
// can list which specific accounts would be removed.
for j := range users {
out = append(out, StaleAccountCandidate{
AccountID: acc.ID,
AccountName: acc.Name,
UserID: users[j].ID,
UserEmail: users[j].Email,
LastActiveAt: users[j].LastActiveAt,
})
}
}
return out, nil
}
// CleanupStaleAccounts hard-deletes every account eligible for the
// admin "Удалить старые" sweep. Returns the number of accounts that
// were deleted. The matching users (each of whom only belonged to one
// account) are deleted by HardDeleteAccount's cascading access cleanup
// only if they no longer have any other account — that final teardown
// is done here.
func CleanupStaleAccounts() (int, error) {
candidates, err := FindStaleAccounts()
if err != nil {
return 0, err
}
accountIDs := make([]int64, 0, len(candidates))
seen := make(map[int64]bool, len(candidates))
for _, c := range candidates {
if !seen[c.AccountID] {
seen[c.AccountID] = true
accountIDs = append(accountIDs, c.AccountID)
}
}
deleted := 0
for _, accountID := range accountIDs {
if err := HardDeleteAccount(accountID); err != nil {
log.Printf("CleanupStaleAccounts: failed to delete account %d: %v", accountID, err)
continue
}
deleted++
}
// Users that no longer have any accesses after the cascade are
// clearly orphaned — purge them so the auth layer doesn't keep
// dangling rows around. We bypass HardDeleteUser here because the
// account-level data (monitors, checks, groups, etc.) was already
// removed by the HardDeleteAccount loop above, so only the user
// row and the dangling identities / contacts need cleanup.
if deleted > 0 {
orphans := make([]int64, 0)
err := DB().
Table("users").
Select("users.id").
Joins("LEFT JOIN accesses ON accesses.user_id = users.id").
Where("accesses.id IS NULL").
Pluck("users.id", &orphans).Error
if err != nil {
log.Printf("CleanupStaleAccounts: orphan user scan failed: %v", err)
return deleted, nil
}
for _, userID := range orphans {
tx := DB().Begin()
if err := tx.Exec("DELETE FROM identities WHERE user_id = ?", userID).Error; err != nil {
tx.Rollback()
log.Printf("CleanupStaleAccounts: identities delete failed for user %d: %v", userID, err)
continue
}
if err := tx.Where("user_id = ? AND account_id IS NULL", userID).Delete(&Contact{}).Error; err != nil {
tx.Rollback()
log.Printf("CleanupStaleAccounts: contacts delete failed for user %d: %v", userID, err)
continue
}
if err := tx.Where("user_id = ?", userID).Delete(&ApiKey{}).Error; err != nil {
tx.Rollback()
log.Printf("CleanupStaleAccounts: api_keys delete failed for user %d: %v", userID, err)
continue
}
if err := tx.Delete(&User{}, userID).Error; err != nil {
tx.Rollback()
log.Printf("CleanupStaleAccounts: user delete failed for %d: %v", userID, err)
continue
}
if err := tx.Commit().Error; err != nil {
log.Printf("CleanupStaleAccounts: commit failed for user %d: %v", userID, err)
}
}
}
if deleted > 0 {
log.Printf("CleanupStaleAccounts: hard-deleted %d stale account(s)", deleted)
}
return deleted, nil
}