mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-17 04:56:52 -04:00
* fix(auth): cascade user deletion across all owned data on PostgreSQL Deleting a user from the admin UI in distributed mode (PostgreSQL auth DB) returned "user not found" even when the user clearly existed. The old handler ignored result.Error and only checked RowsAffected, so a foreign-key constraint violation surfaced as a misleading 404. Two issues drove this: 1. invite_codes.created_by / used_by reference users(id) but the InviteCode model declared the FKs without ON DELETE CASCADE. On PostgreSQL the engine therefore rejected the user delete with NO ACTION whenever the user had ever issued or consumed an invite. On SQLite (default in single-node mode) FKs are not enforced, so the bug never appeared there. 2. Several owned tables were never cleaned up regardless of dialect: user_permissions and quota_rules relied on CASCADE that does not fire under SQLite, and usage_records have no FK at all and were left orphaned in every dialect. Introduce auth.DeleteUserCascade which runs the full cleanup in a single transaction: drop invites authored by the user, NULL used_by on invites they consumed (preserves the audit trail), and explicitly wipe sessions, API keys, permissions, quota rules, and usage metrics before deleting the user. The in-memory quota cache is invalidated after commit so a recreated user with the same id never sees stale entries. The HTTP handler now maps the helper's errors to proper status codes — real failures surface as 500 with the cause instead of being swallowed as "not found". Add Ginkgo regression coverage in core/http/auth/users_test.go and core/http/routes/auth_test.go covering invite cleanup, used_by null-out, full data wipe, and the FK-enforced original failure mode (via PRAGMA foreign_keys=ON to mirror PostgreSQL behavior on SQLite). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7 [Claude Code] * chore(deps): bump LocalAGI/LocalRecall — pull in go-fitz PDF extraction Pulls LocalAGI@main (facd888) and LocalRecall@v0.6.0. The latter swaps PDF text extraction from dslipak/pdf to gen2brain/go-fitz (libmupdf bindings) and wraps it in a 60s goroutine timeout — previously certain PDFs (broken xref tables, encrypted, image-only without OCR) would hang indefinitely inside r.GetPlainText() and poison the upload queue. Pure dep bump, no LocalAI source changes. Indirect graph picks up go-fitz + purego + ffi; drops dslipak/pdf. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
70 lines
2.5 KiB
Go
70 lines
2.5 KiB
Go
package auth
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// DeleteUserCascade removes a user and all of their owned data.
|
|
//
|
|
// PostgreSQL strictly enforces every foreign key, while SQLite only enforces
|
|
// them when foreign_keys=ON. So we explicitly delete every dependent row
|
|
// instead of relying on `ON DELETE CASCADE`, otherwise:
|
|
//
|
|
// - on PostgreSQL the user delete fails with a constraint violation when
|
|
// the user authored or consumed any invite codes (the InviteCode FKs are
|
|
// declared without an OnDelete: CASCADE constraint), and
|
|
// - usage_records have no FK at all, so they would be left orphaned in any
|
|
// dialect.
|
|
//
|
|
// It also clears the in-memory quota cache for the user.
|
|
//
|
|
// Returns gorm.ErrRecordNotFound when the user does not exist.
|
|
func DeleteUserCascade(db *gorm.DB, userID string) error {
|
|
err := db.Transaction(func(tx *gorm.DB) error {
|
|
// Drop invites authored by this user; the admin who issued them is gone.
|
|
if err := tx.Where("created_by = ?", userID).Delete(&InviteCode{}).Error; err != nil {
|
|
return fmt.Errorf("delete invites created by user: %w", err)
|
|
}
|
|
// Preserve audit trail for invites consumed by this user — null the FK.
|
|
if err := tx.Model(&InviteCode{}).Where("used_by = ?", userID).Update("used_by", nil).Error; err != nil {
|
|
return fmt.Errorf("clear used_by on invites: %w", err)
|
|
}
|
|
// Wipe collected metrics; they have no FK and would otherwise orphan.
|
|
if err := tx.Where("user_id = ?", userID).Delete(&UsageRecord{}).Error; err != nil {
|
|
return fmt.Errorf("delete usage records: %w", err)
|
|
}
|
|
// Explicit deletes for the CASCADE-backed children too — they're cheap
|
|
// and keep behaviour identical across SQLite (FKs may be OFF) and
|
|
// PostgreSQL.
|
|
if err := tx.Where("user_id = ?", userID).Delete(&Session{}).Error; err != nil {
|
|
return fmt.Errorf("delete sessions: %w", err)
|
|
}
|
|
if err := tx.Where("user_id = ?", userID).Delete(&UserAPIKey{}).Error; err != nil {
|
|
return fmt.Errorf("delete api keys: %w", err)
|
|
}
|
|
if err := tx.Where("user_id = ?", userID).Delete(&UserPermission{}).Error; err != nil {
|
|
return fmt.Errorf("delete permissions: %w", err)
|
|
}
|
|
if err := tx.Where("user_id = ?", userID).Delete(&QuotaRule{}).Error; err != nil {
|
|
return fmt.Errorf("delete quota rules: %w", err)
|
|
}
|
|
|
|
result := tx.Where("id = ?", userID).Delete(&User{})
|
|
if result.Error != nil {
|
|
return fmt.Errorf("delete user: %w", result.Error)
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
quotaCache.invalidateUser(userID)
|
|
return nil
|
|
}
|