583 строки
19 KiB
Go
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", ¬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
|
|
}
|