Files
LocalAI/core/http/auth/users.go
LocalAI [bot] 392fc9ce3d fix(auth): cascade user deletion across all owned data on PostgreSQL (#9702)
* 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>
2026-05-07 08:28:58 +02:00

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
}