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", ¬ificationIDs).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 }