Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions core/http/auth/users.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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
}
114 changes: 114 additions & 0 deletions core/http/auth/users_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
//go:build auth

package auth_test

import (
"time"

"github.com/google/uuid"
"github.com/mudler/LocalAI/core/http/auth"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gorm.io/gorm"
)

// Regression coverage for the production bug where deleting a user from the
// distributed-mode (PostgreSQL) admin UI returned "user not found" because the
// old delete path ignored result.Error and left several tables uncleaned.
var _ = Describe("DeleteUserCascade", Label("auth"), func() {
var db *gorm.DB

BeforeEach(func() {
db = testDB()
})

It("returns ErrRecordNotFound when the user does not exist", func() {
err := auth.DeleteUserCascade(db, uuid.New().String())
Expect(err).To(Equal(gorm.ErrRecordNotFound))
})

It("removes invite codes the user authored", func() {
target := createTestUser(db, "author@test.com", auth.RoleAdmin, auth.ProviderLocal)
Expect(db.Create(&auth.InviteCode{
ID: uuid.New().String(), Code: "code-author-1", CodePrefix: "code-aut",
CreatedBy: target.ID, ExpiresAt: time.Now().Add(time.Hour),
}).Error).ToNot(HaveOccurred())

Expect(auth.DeleteUserCascade(db, target.ID)).ToNot(HaveOccurred())

var count int64
db.Model(&auth.InviteCode{}).Where("created_by = ?", target.ID).Count(&count)
Expect(count).To(Equal(int64(0)))
})

It("nulls used_by on invite codes the user consumed but keeps the audit row", func() {
admin := createTestUser(db, "admin-keep@test.com", auth.RoleAdmin, auth.ProviderLocal)
target := createTestUser(db, "consumer@test.com", auth.RoleUser, auth.ProviderLocal)

usedBy := target.ID
now := time.Now()
invite := &auth.InviteCode{
ID: uuid.New().String(), Code: "code-used-1", CodePrefix: "code-use",
CreatedBy: admin.ID, UsedBy: &usedBy, UsedAt: &now,
ExpiresAt: now.Add(time.Hour),
}
Expect(db.Create(invite).Error).ToNot(HaveOccurred())

Expect(auth.DeleteUserCascade(db, target.ID)).ToNot(HaveOccurred())

var refreshed auth.InviteCode
Expect(db.First(&refreshed, "id = ?", invite.ID).Error).ToNot(HaveOccurred())
Expect(refreshed.UsedBy).To(BeNil(), "used_by should be cleared so the FK no longer points to the deleted user")
})

It("wipes sessions, api keys, permissions, quotas, and usage metrics", func() {
target := createTestUser(db, "owns-data@test.com", auth.RoleUser, auth.ProviderLocal)

_ = createTestSession(db, target.ID)
_, _, err := auth.CreateAPIKey(db, target.ID, "k1", auth.RoleUser, "", nil)
Expect(err).ToNot(HaveOccurred())
Expect(auth.UpdateUserPermissions(db, target.ID, auth.PermissionMap{auth.FeatureChat: true})).ToNot(HaveOccurred())
max := int64(100)
_, err = auth.CreateOrUpdateQuotaRule(db, target.ID, "", &max, nil, 3600)
Expect(err).ToNot(HaveOccurred())
Expect(auth.RecordUsage(db, &auth.UsageRecord{
UserID: target.ID, UserName: target.Name, Model: "test-model",
Endpoint: "/v1/chat/completions", PromptTokens: 5, CompletionTokens: 10, TotalTokens: 15,
})).ToNot(HaveOccurred())

Expect(auth.DeleteUserCascade(db, target.ID)).ToNot(HaveOccurred())

var sessions, keys, perms, quotas, usage int64
db.Model(&auth.Session{}).Where("user_id = ?", target.ID).Count(&sessions)
db.Model(&auth.UserAPIKey{}).Where("user_id = ?", target.ID).Count(&keys)
db.Model(&auth.UserPermission{}).Where("user_id = ?", target.ID).Count(&perms)
db.Model(&auth.QuotaRule{}).Where("user_id = ?", target.ID).Count(&quotas)
db.Model(&auth.UsageRecord{}).Where("user_id = ?", target.ID).Count(&usage)

Expect(sessions).To(Equal(int64(0)))
Expect(keys).To(Equal(int64(0)))
Expect(perms).To(Equal(int64(0)))
Expect(quotas).To(Equal(int64(0)))
Expect(usage).To(Equal(int64(0)), "usage metrics must be removed alongside the user")
})

It("succeeds with foreign keys enforced — the production failure mode", func() {
// Mirror PostgreSQL's strict FK behavior on the SQLite test DB. Without
// the cleanup of invite_codes.created_by, the engine would reject the
// user delete with a constraint violation, which the old handler then
// surfaced as a misleading 404.
Expect(db.Exec("PRAGMA foreign_keys = ON").Error).ToNot(HaveOccurred())

target := createTestUser(db, "fk-author@test.com", auth.RoleAdmin, auth.ProviderLocal)
Expect(db.Create(&auth.InviteCode{
ID: uuid.New().String(), Code: "code-fk-1", CodePrefix: "code-fk1",
CreatedBy: target.ID, ExpiresAt: time.Now().Add(time.Hour),
}).Error).ToNot(HaveOccurred())

Expect(auth.DeleteUserCascade(db, target.ID)).ToNot(HaveOccurred())

var users int64
db.Model(&auth.User{}).Where("id = ?", target.ID).Count(&users)
Expect(users).To(Equal(int64(0)))
})
})
12 changes: 5 additions & 7 deletions core/http/routes/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -933,13 +933,11 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete yourself"})
}

// Cascade: delete sessions and API keys
db.Where("user_id = ?", targetID).Delete(&auth.Session{})
db.Where("user_id = ?", targetID).Delete(&auth.UserAPIKey{})

result := db.Where("id = ?", targetID).Delete(&auth.User{})
if result.RowsAffected == 0 {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
if err := auth.DeleteUserCascade(db, targetID); err != nil {
if err == gorm.ErrRecordNotFound {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete user: " + err.Error()})
}

return c.JSON(http.StatusOK, map[string]string{"message": "user deleted"})
Expand Down
112 changes: 107 additions & 5 deletions core/http/routes/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"time"

"github.com/google/uuid"
"github.com/labstack/echo/v4"
Expand Down Expand Up @@ -276,11 +277,11 @@ func newTestAuthApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo
if currentUser.ID == targetID {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete yourself"})
}
db.Where("user_id = ?", targetID).Delete(&auth.Session{})
db.Where("user_id = ?", targetID).Delete(&auth.UserAPIKey{})
result := db.Where("id = ?", targetID).Delete(&auth.User{})
if result.RowsAffected == 0 {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
if err := auth.DeleteUserCascade(db, targetID); err != nil {
if err == gorm.ErrRecordNotFound {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete user: " + err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "user deleted"})
}, adminMw)
Expand Down Expand Up @@ -686,6 +687,107 @@ var _ = Describe("Auth Routes", Label("auth"), func() {
rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusForbidden))
})

// Regression coverage for the production bug: in distributed mode the
// auth DB is PostgreSQL, which strictly enforces foreign keys. The old
// handler did not clean up invite_codes, user_permissions, quota_rules,
// or usage_records, which either caused FK violations (surfaced as a
// misleading 404 "user not found") or left orphan rows after delete.
It("removes invite codes the user authored", func() {
admin := createRouteTestUser(db, "admin-inv-author@test.com", auth.RoleAdmin)
target := createRouteTestUser(db, "deletes-author@test.com", auth.RoleAdmin)
Expect(db.Create(&auth.InviteCode{
ID: uuid.New().String(), Code: "code-authored", CodePrefix: "code-aut",
CreatedBy: target.ID, ExpiresAt: time.Now().Add(time.Hour),
}).Error).ToNot(HaveOccurred())
sessionID, _ := auth.CreateSession(db, admin.ID, "")
app := newTestAuthApp(db, appConfig)

rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))

var count int64
db.Model(&auth.InviteCode{}).Where("created_by = ?", target.ID).Count(&count)
Expect(count).To(Equal(int64(0)))
})

It("nulls used_by on invite codes the user consumed", func() {
admin := createRouteTestUser(db, "admin-inv-consumer@test.com", auth.RoleAdmin)
target := createRouteTestUser(db, "deletes-consumer@test.com", auth.RoleUser)
usedBy := target.ID
now := time.Now()
Expect(db.Create(&auth.InviteCode{
ID: uuid.New().String(), Code: "code-used", CodePrefix: "code-use",
CreatedBy: admin.ID, UsedBy: &usedBy, UsedAt: &now,
ExpiresAt: now.Add(time.Hour),
}).Error).ToNot(HaveOccurred())
sessionID, _ := auth.CreateSession(db, admin.ID, "")
app := newTestAuthApp(db, appConfig)

rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))

// Audit row stays, but no longer points to the deleted user.
var stale int64
db.Model(&auth.InviteCode{}).Where("used_by = ?", target.ID).Count(&stale)
Expect(stale).To(Equal(int64(0)))
var total int64
db.Model(&auth.InviteCode{}).Where("created_by = ?", admin.ID).Count(&total)
Expect(total).To(Equal(int64(1)))
})

It("wipes permissions, quotas, and usage metrics", func() {
admin := createRouteTestUser(db, "admin-clean@test.com", auth.RoleAdmin)
target := createRouteTestUser(db, "deletes-clean@test.com", auth.RoleUser)

Expect(auth.UpdateUserPermissions(db, target.ID, auth.PermissionMap{auth.FeatureChat: true})).ToNot(HaveOccurred())
max := int64(100)
_, err := auth.CreateOrUpdateQuotaRule(db, target.ID, "", &max, nil, 3600)
Expect(err).ToNot(HaveOccurred())
Expect(auth.RecordUsage(db, &auth.UsageRecord{
UserID: target.ID, UserName: target.Name, Model: "test-model",
Endpoint: "/v1/chat/completions", PromptTokens: 5, CompletionTokens: 10, TotalTokens: 15,
})).ToNot(HaveOccurred())

sessionID, _ := auth.CreateSession(db, admin.ID, "")
app := newTestAuthApp(db, appConfig)

rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK))

var perms, quotas, usage int64
db.Model(&auth.UserPermission{}).Where("user_id = ?", target.ID).Count(&perms)
db.Model(&auth.QuotaRule{}).Where("user_id = ?", target.ID).Count(&quotas)
db.Model(&auth.UsageRecord{}).Where("user_id = ?", target.ID).Count(&usage)
Expect(perms).To(Equal(int64(0)))
Expect(quotas).To(Equal(int64(0)))
Expect(usage).To(Equal(int64(0)))
})

It("returns 200 even when foreign keys are enforced and the user authored invites", func() {
// Mirror PostgreSQL's strict FK behavior on the SQLite test DB. This
// exercises exactly the production failure: without the cleanup,
// the user delete would be rejected by the engine and the handler
// would surface a misleading 404.
Expect(db.Exec("PRAGMA foreign_keys = ON").Error).ToNot(HaveOccurred())

admin := createRouteTestUser(db, "admin-fk@test.com", auth.RoleAdmin)
target := createRouteTestUser(db, "deletes-fk@test.com", auth.RoleAdmin)
Expect(db.Create(&auth.InviteCode{
ID: uuid.New().String(), Code: "code-fk", CodePrefix: "code-fk1",
CreatedBy: target.ID, ExpiresAt: time.Now().Add(time.Hour),
}).Error).ToNot(HaveOccurred())

sessionID, _ := auth.CreateSession(db, admin.ID, "")
app := newTestAuthApp(db, appConfig)

rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID))
Expect(rec.Code).To(Equal(http.StatusOK), "body=%s", rec.Body.String())

var users int64
db.Model(&auth.User{}).Where("id = ?", target.ID).Count(&users)
Expect(users).To(Equal(int64(0)))
})
})

Context("POST /api/auth/register", func() {
Expand Down
11 changes: 4 additions & 7 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,11 @@ require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/gen2brain/go-fitz v1.24.15 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jupiterrider/ffi v0.5.0 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect
Expand Down Expand Up @@ -142,7 +144,6 @@ require (
github.com/bwmarrin/discordgo v0.29.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.5.1 // indirect
github.com/dslipak/pdf v0.0.2 // indirect
github.com/emersion/go-imap/v2 v2.0.0-beta.5 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
Expand Down Expand Up @@ -170,8 +171,8 @@ require (
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/mudler/LocalAGI v0.0.0-20260504165100-e83bf515d010
github.com/mudler/localrecall v0.5.10-0.20260504162944-6138c1f535ab // indirect
github.com/mudler/LocalAGI v0.0.0-20260506230719-facd8881b135
github.com/mudler/localrecall v0.6.0 // indirect
github.com/mudler/skillserver v0.0.6
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 // indirect
Expand Down Expand Up @@ -254,7 +255,6 @@ require (
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/otiai10/mint v1.6.3 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v2 v2.2.12 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/ice/v4 v4.2.2 // indirect
github.com/pion/interceptor v0.1.44 // indirect
Expand All @@ -266,9 +266,7 @@ require (
github.com/pion/sctp v1.9.4 // indirect
github.com/pion/sdp/v3 v3.0.18 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun v0.6.1 // indirect
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pion/webrtc/v4 v4.2.11
github.com/prometheus/otlptranslator v1.0.0 // indirect
Expand Down Expand Up @@ -324,7 +322,6 @@ require (
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
github.com/go-audio/audio v1.0.0
github.com/go-audio/riff v1.0.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
Expand Down
Loading
Loading