From 25e72784b8ea4af3dec001e887add7ee2e6e859a Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 21 Mar 2026 08:33:46 +0000 Subject: [PATCH 1/2] feat: add quota system Signed-off-by: Ettore Di Giacinto --- core/http/app.go | 1 + core/http/auth/db.go | 6 +- core/http/auth/middleware.go | 50 ++++ core/http/auth/quota.go | 366 +++++++++++++++++++++++++ core/http/react-ui/src/pages/Users.jsx | 232 +++++++++++++++- core/http/react-ui/src/pages/auth.css | 212 +++++++++++++- core/http/react-ui/src/utils/api.js | 10 + core/http/routes/auth.go | 128 ++++++++- 8 files changed, 990 insertions(+), 15 deletions(-) create mode 100644 core/http/auth/quota.go diff --git a/core/http/app.go b/core/http/app.go index 696d394d254e..587fddfe1101 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -221,6 +221,7 @@ func API(application *application.Application) (*echo.Echo, error) { if application.AuthDB() != nil { e.Use(auth.RequireRouteFeature(application.AuthDB())) e.Use(auth.RequireModelAccess(application.AuthDB())) + e.Use(auth.RequireQuota(application.AuthDB())) } // CORS middleware diff --git a/core/http/auth/db.go b/core/http/auth/db.go index f3b2f0d3866f..d860e5068a92 100644 --- a/core/http/auth/db.go +++ b/core/http/auth/db.go @@ -33,10 +33,14 @@ func InitDB(databaseURL string) (*gorm.DB, error) { return nil, fmt.Errorf("failed to open auth database: %w", err) } - if err := db.AutoMigrate(&User{}, &Session{}, &UserAPIKey{}, &UsageRecord{}, &UserPermission{}, &InviteCode{}); err != nil { + if err := db.AutoMigrate(&User{}, &Session{}, &UserAPIKey{}, &UsageRecord{}, &UserPermission{}, &InviteCode{}, &QuotaRule{}); err != nil { return nil, fmt.Errorf("failed to migrate auth tables: %w", err) } + // Backfill: users created before the provider column existed have an empty + // provider — treat them as local accounts so the UI can identify them. + db.Exec("UPDATE users SET provider = ? WHERE provider = '' OR provider IS NULL", ProviderLocal) + // Create composite index on users(provider, subject) for fast OAuth lookups if err := db.Exec("CREATE INDEX IF NOT EXISTS idx_users_provider_subject ON users(provider, subject)").Error; err != nil { // Ignore error on postgres if index already exists diff --git a/core/http/auth/middleware.go b/core/http/auth/middleware.go index 9525b9f22a3a..4799d51fa7f8 100644 --- a/core/http/auth/middleware.go +++ b/core/http/auth/middleware.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/subtle" "encoding/json" + "fmt" "io" "net/http" "strings" @@ -370,6 +371,55 @@ func extractModelFromRequest(c echo.Context) string { return "" } +// RequireQuota returns a global middleware that enforces per-user quota rules. +// If no auth DB is provided, it's a no-op. Admin users always bypass quotas. +// Only inference routes (those listed in RouteFeatureRegistry) count toward quota. +func RequireQuota(db *gorm.DB) echo.MiddlewareFunc { + if db == nil { + return NoopMiddleware() + } + // Pre-build lookup set from RouteFeatureRegistry — only these routes + // should count toward quota. Mirrors RequireRouteFeature's approach. + inferenceRoutes := map[string]bool{} + for _, rf := range RouteFeatureRegistry { + inferenceRoutes[rf.Method+":"+rf.Pattern] = true + } + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Only enforce quotas on inference routes + path := c.Path() + method := c.Request().Method + if !inferenceRoutes[method+":"+path] && !inferenceRoutes["*:"+path] { + return next(c) + } + + user := GetUser(c) + if user == nil { + return next(c) + } + if user.Role == RoleAdmin { + return next(c) + } + + model := extractModelFromRequest(c) + + exceeded, retryAfter, msg := QuotaExceeded(db, user.ID, model) + if exceeded { + c.Response().Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter)) + return c.JSON(http.StatusTooManyRequests, schema.ErrorResponse{ + Error: &schema.APIError{ + Message: msg, + Code: http.StatusTooManyRequests, + Type: "quota_exceeded", + }, + }) + } + + return next(c) + } + } +} + // tryAuthenticate attempts to authenticate the request using the database. func tryAuthenticate(c echo.Context, db *gorm.DB, appConfig *config.ApplicationConfig) *User { hmacSecret := appConfig.Auth.APIKeyHMACSecret diff --git a/core/http/auth/quota.go b/core/http/auth/quota.go new file mode 100644 index 000000000000..a79e1861b607 --- /dev/null +++ b/core/http/auth/quota.go @@ -0,0 +1,366 @@ +package auth + +import ( + "fmt" + "sync" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// QuotaRule defines a rate/token limit for a user, optionally scoped to a model. +type QuotaRule struct { + ID string `gorm:"primaryKey;size:36"` + UserID string `gorm:"size:36;uniqueIndex:idx_quota_user_model"` + Model string `gorm:"size:255;uniqueIndex:idx_quota_user_model"` // "" = all models + MaxRequests *int64 // nil = no request limit + MaxTotalTokens *int64 // nil = no token limit + WindowSeconds int64 // e.g., 3600 = 1h + CreatedAt time.Time + UpdatedAt time.Time + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` +} + +// QuotaStatus is returned to clients with current usage included. +type QuotaStatus struct { + ID string `json:"id"` + Model string `json:"model"` + MaxRequests *int64 `json:"max_requests"` + MaxTotalTokens *int64 `json:"max_total_tokens"` + Window string `json:"window"` + CurrentRequests int64 `json:"current_requests"` + CurrentTokens int64 `json:"current_total_tokens"` + ResetsAt string `json:"resets_at,omitempty"` +} + +// ── CRUD ── + +// CreateOrUpdateQuotaRule upserts a quota rule for the given user+model. +func CreateOrUpdateQuotaRule(db *gorm.DB, userID, model string, maxReqs, maxTokens *int64, windowSecs int64) (*QuotaRule, error) { + var existing QuotaRule + err := db.Where("user_id = ? AND model = ?", userID, model).First(&existing).Error + if err == gorm.ErrRecordNotFound { + rule := QuotaRule{ + ID: uuid.New().String(), + UserID: userID, + Model: model, + MaxRequests: maxReqs, + MaxTotalTokens: maxTokens, + WindowSeconds: windowSecs, + } + if err := db.Create(&rule).Error; err != nil { + return nil, err + } + quotaCache.invalidateUser(userID) + return &rule, nil + } + if err != nil { + return nil, err + } + existing.MaxRequests = maxReqs + existing.MaxTotalTokens = maxTokens + existing.WindowSeconds = windowSecs + if err := db.Save(&existing).Error; err != nil { + return nil, err + } + quotaCache.invalidateUser(userID) + return &existing, nil +} + +// ListQuotaRules returns all quota rules for a user. +func ListQuotaRules(db *gorm.DB, userID string) ([]QuotaRule, error) { + var rules []QuotaRule + if err := db.Where("user_id = ?", userID).Order("model ASC").Find(&rules).Error; err != nil { + return nil, err + } + return rules, nil +} + +// DeleteQuotaRule removes a quota rule by ID (scoped to user for safety). +func DeleteQuotaRule(db *gorm.DB, ruleID, userID string) error { + result := db.Where("id = ? AND user_id = ?", ruleID, userID).Delete(&QuotaRule{}) + if result.RowsAffected == 0 { + return fmt.Errorf("quota rule not found") + } + quotaCache.invalidateUser(userID) + return nil +} + +// ── Usage queries ── + +type usageCounts struct { + RequestCount int64 + TotalTokens int64 +} + +// getUsageSince counts requests and tokens for a user since the given time. +func getUsageSince(db *gorm.DB, userID string, since time.Time, model string) (usageCounts, error) { + var result usageCounts + q := db.Model(&UsageRecord{}). + Select("COUNT(*) as request_count, COALESCE(SUM(total_tokens), 0) as total_tokens"). + Where("user_id = ? AND created_at >= ?", userID, since) + if model != "" { + q = q.Where("model = ?", model) + } + if err := q.Row().Scan(&result.RequestCount, &result.TotalTokens); err != nil { + return result, err + } + return result, nil +} + +// GetQuotaStatuses returns all quota rules for a user with current usage. +func GetQuotaStatuses(db *gorm.DB, userID string) ([]QuotaStatus, error) { + rules, err := ListQuotaRules(db, userID) + if err != nil { + return nil, err + } + statuses := make([]QuotaStatus, 0, len(rules)) + now := time.Now() + for _, r := range rules { + windowStart := now.Add(-time.Duration(r.WindowSeconds) * time.Second) + counts, err := getUsageSince(db, userID, windowStart, r.Model) + if err != nil { + counts = usageCounts{} + } + statuses = append(statuses, QuotaStatus{ + ID: r.ID, + Model: r.Model, + MaxRequests: r.MaxRequests, + MaxTotalTokens: r.MaxTotalTokens, + Window: formatWindowDuration(r.WindowSeconds), + CurrentRequests: counts.RequestCount, + CurrentTokens: counts.TotalTokens, + ResetsAt: windowStart.Add(time.Duration(r.WindowSeconds) * time.Second).UTC().Format(time.RFC3339), + }) + } + return statuses, nil +} + +// ── Quota check (used by middleware) ── + +// QuotaExceeded checks whether the user has exceeded any applicable quota rule. +// Returns (exceeded bool, retryAfterSeconds int64, message string). +func QuotaExceeded(db *gorm.DB, userID, model string) (bool, int64, string) { + rules := quotaCache.getRules(db, userID) + if len(rules) == 0 { + return false, 0, "" + } + + now := time.Now() + + for _, r := range rules { + // Check if rule applies: model-specific rules match that model, global (empty) applies to all. + if r.Model != "" && r.Model != model { + continue + } + + windowStart := now.Add(-time.Duration(r.WindowSeconds) * time.Second) + retryAfter := r.WindowSeconds // worst case: full window + + // Try cache first + counts, ok := quotaCache.getUsage(userID, r.Model, windowStart) + if !ok { + var err error + counts, err = getUsageSince(db, userID, windowStart, r.Model) + if err != nil { + continue // on error, don't block the request + } + quotaCache.setUsage(userID, r.Model, windowStart, counts) + } + + if r.MaxRequests != nil && counts.RequestCount >= *r.MaxRequests { + scope := "all models" + if r.Model != "" { + scope = "model " + r.Model + } + return true, retryAfter, fmt.Sprintf( + "Request quota exceeded for %s: %d/%d requests in %s window", + scope, counts.RequestCount, *r.MaxRequests, formatWindowDuration(r.WindowSeconds), + ) + } + if r.MaxTotalTokens != nil && counts.TotalTokens >= *r.MaxTotalTokens { + scope := "all models" + if r.Model != "" { + scope = "model " + r.Model + } + return true, retryAfter, fmt.Sprintf( + "Token quota exceeded for %s: %d/%d tokens in %s window", + scope, counts.TotalTokens, *r.MaxTotalTokens, formatWindowDuration(r.WindowSeconds), + ) + } + } + + // Optimistic increment: bump cached counters so subsequent requests in the + // same cache window see an updated count without re-querying the DB. + for _, r := range rules { + if r.Model != "" && r.Model != model { + continue + } + windowStart := now.Add(-time.Duration(r.WindowSeconds) * time.Second) + quotaCache.incrementUsage(userID, r.Model, windowStart) + } + + return false, 0, "" +} + +// ── In-memory cache ── + +var quotaCache = newQuotaCacheStore() + +type quotaCacheStore struct { + mu sync.RWMutex + rules map[string]cachedRules // userID -> rules + usage map[string]cachedUsage // "userID|model|windowStart" -> counts +} + +type cachedRules struct { + rules []QuotaRule + fetchedAt time.Time +} + +type cachedUsage struct { + counts usageCounts + fetchedAt time.Time +} + +func newQuotaCacheStore() *quotaCacheStore { + c := "aCacheStore{ + rules: make(map[string]cachedRules), + usage: make(map[string]cachedUsage), + } + go c.cleanupLoop() + return c +} + +const ( + rulesCacheTTL = 30 * time.Second + usageCacheTTL = 10 * time.Second +) + +func (c *quotaCacheStore) getRules(db *gorm.DB, userID string) []QuotaRule { + c.mu.RLock() + cached, ok := c.rules[userID] + c.mu.RUnlock() + if ok && time.Since(cached.fetchedAt) < rulesCacheTTL { + return cached.rules + } + + rules, err := ListQuotaRules(db, userID) + if err != nil { + return nil + } + c.mu.Lock() + c.rules[userID] = cachedRules{rules: rules, fetchedAt: time.Now()} + c.mu.Unlock() + return rules +} + +func (c *quotaCacheStore) invalidateUser(userID string) { + c.mu.Lock() + delete(c.rules, userID) + c.mu.Unlock() +} + +func usageKey(userID, model string, windowStart time.Time) string { + return userID + "|" + model + "|" + windowStart.Truncate(time.Second).Format(time.RFC3339) +} + +func (c *quotaCacheStore) getUsage(userID, model string, windowStart time.Time) (usageCounts, bool) { + key := usageKey(userID, model, windowStart) + c.mu.RLock() + cached, ok := c.usage[key] + c.mu.RUnlock() + if ok && time.Since(cached.fetchedAt) < usageCacheTTL { + return cached.counts, true + } + return usageCounts{}, false +} + +func (c *quotaCacheStore) setUsage(userID, model string, windowStart time.Time, counts usageCounts) { + key := usageKey(userID, model, windowStart) + c.mu.Lock() + c.usage[key] = cachedUsage{counts: counts, fetchedAt: time.Now()} + c.mu.Unlock() +} + +func (c *quotaCacheStore) incrementUsage(userID, model string, windowStart time.Time) { + key := usageKey(userID, model, windowStart) + c.mu.Lock() + if cached, ok := c.usage[key]; ok { + cached.counts.RequestCount++ + c.usage[key] = cached + } + c.mu.Unlock() +} + +func (c *quotaCacheStore) cleanupLoop() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for range ticker.C { + c.mu.Lock() + now := time.Now() + for k, v := range c.rules { + if now.Sub(v.fetchedAt) > rulesCacheTTL*2 { + delete(c.rules, k) + } + } + for k, v := range c.usage { + if now.Sub(v.fetchedAt) > usageCacheTTL*2 { + delete(c.usage, k) + } + } + c.mu.Unlock() + } +} + +// ── Helpers ── + +// ParseWindowDuration converts a human-friendly window string to seconds. +func ParseWindowDuration(s string) (int64, error) { + switch s { + case "1m": + return 60, nil + case "5m": + return 300, nil + case "1h": + return 3600, nil + case "6h": + return 21600, nil + case "1d": + return 86400, nil + case "7d": + return 604800, nil + case "30d": + return 2592000, nil + } + // Try Go duration parsing as fallback + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("invalid window duration: %s", s) + } + return int64(d.Seconds()), nil +} + +// formatWindowDuration converts seconds to a human-friendly string. +func formatWindowDuration(secs int64) string { + switch secs { + case 60: + return "1m" + case 300: + return "5m" + case 3600: + return "1h" + case 21600: + return "6h" + case 86400: + return "1d" + case 604800: + return "7d" + case 2592000: + return "30d" + default: + d := time.Duration(secs) * time.Second + return d.String() + } +} diff --git a/core/http/react-ui/src/pages/Users.jsx b/core/http/react-ui/src/pages/Users.jsx index 205f89eaf4e5..707a2e079e71 100644 --- a/core/http/react-ui/src/pages/Users.jsx +++ b/core/http/react-ui/src/pages/Users.jsx @@ -52,6 +52,7 @@ function PermissionSummary({ user, onClick }) { const generalOn = generalFeatures.filter(f => perms[f]).length const modelRestricted = user.allowed_models?.enabled + const quotaCount = (user.quotas || []).length return ( ) } +const WINDOW_OPTIONS = [ + { value: '1m', label: '1 minute' }, + { value: '5m', label: '5 minutes' }, + { value: '1h', label: '1 hour' }, + { value: '6h', label: '6 hours' }, + { value: '1d', label: '1 day' }, + { value: '7d', label: '7 days' }, + { value: '30d', label: '30 days' }, +] + function PermissionsModal({ user, featureMeta, availableModels, onClose, onSave, addToast }) { const [permissions, setPermissions] = useState({ ...(user.permissions || {}) }) const [allowedModels, setAllowedModels] = useState(user.allowed_models || { enabled: false, models: [] }) + const [quotas, setQuotas] = useState((user.quotas || []).map(q => ({ ...q, _dirty: false }))) + const [deletedQuotaIds, setDeletedQuotaIds] = useState([]) const [saving, setSaving] = useState(false) const apiFeatures = featureMeta?.api_features || [] @@ -114,12 +128,57 @@ function PermissionsModal({ user, featureMeta, availableModels, onClose, onSave, } } + const addQuota = () => { + setQuotas(prev => [...prev, { + id: null, model: '', max_requests: null, max_total_tokens: null, window: '1h', + current_requests: 0, current_total_tokens: 0, _dirty: true, _new: true, + }]) + } + + const updateQuota = (idx, field, value) => { + setQuotas(prev => prev.map((q, i) => i === idx ? { ...q, [field]: value, _dirty: true } : q)) + } + + const removeQuota = (idx) => { + const q = quotas[idx] + if (q.id && !q._new) { + setDeletedQuotaIds(prev => [...prev, q.id]) + } + setQuotas(prev => prev.filter((_, i) => i !== idx)) + } + const handleSave = async () => { setSaving(true) try { await adminUsersApi.setPermissions(user.id, permissions) await adminUsersApi.setModels(user.id, allowedModels) - onSave(user.id, permissions, allowedModels) + + // Delete removed quotas + for (const qid of deletedQuotaIds) { + await adminUsersApi.deleteQuota(user.id, qid) + } + // Upsert dirty quotas + for (const q of quotas) { + if (q._dirty || q._new) { + await adminUsersApi.setQuota(user.id, { + model: q.model, + max_requests: q.max_requests || null, + max_total_tokens: q.max_total_tokens || null, + window: q.window, + }) + } + } + + // Refetch quotas so caller gets fresh state (including server-assigned IDs and current usage) + let freshQuotas = [] + try { + const qData = await adminUsersApi.getQuotas(user.id) + freshQuotas = Array.isArray(qData) ? qData : qData.quotas || [] + } catch { + // Fall back to local state if refetch fails + freshQuotas = quotas.map(q => ({ ...q, _dirty: false, _new: false })) + } + onSave(user.id, permissions, allowedModels, freshQuotas) addToast(`Permissions updated for ${user.name || user.email}`, 'success') onClose() } catch (err) { @@ -276,6 +335,111 @@ function PermissionsModal({ user, featureMeta, availableModels, onClose, onSave, )} + {/* Quotas */} +
+
+ + + Quotas + + +
+ {quotas.length === 0 ? ( +
+ + No quota rules — unlimited access +
+ ) : ( +
+ {quotas.map((q, idx) => { + const reqPct = (q.max_requests && !q._new) ? Math.min(100, Math.round(((q.current_requests ?? 0) / q.max_requests) * 100)) : null + const tokPct = (q.max_total_tokens && !q._new) ? Math.min(100, Math.round(((q.current_total_tokens ?? 0) / q.max_total_tokens) * 100)) : null + return ( +
+
+ + + +
+
+
+ + updateQuota(idx, 'max_requests', e.target.value ? parseInt(e.target.value, 10) : null)} + min="0" + /> + {reqPct !== null && ( +
+
+
= 90 ? ' quota-progress-fill--danger' : reqPct >= 70 ? ' quota-progress-fill--warning' : ''}`} + style={{ width: `${reqPct}%` }} + /> +
+ {q.current_requests ?? 0} / {q.max_requests} +
+ )} +
+
+ + updateQuota(idx, 'max_total_tokens', e.target.value ? parseInt(e.target.value, 10) : null)} + min="0" + /> + {tokPct !== null && ( +
+
+
= 90 ? ' quota-progress-fill--danger' : tokPct >= 70 ? ' quota-progress-fill--warning' : ''}`} + style={{ width: `${tokPct}%` }} + /> +
+ {(q.current_total_tokens ?? 0).toLocaleString()} / {q.max_total_tokens.toLocaleString()} +
+ )} +
+
+
+ ) + })} +
+ )} +
+ {/* Actions */}
@@ -502,6 +666,9 @@ export default function Users() { const [featureMeta, setFeatureMeta] = useState(null) const [availableModels, setAvailableModels] = useState([]) const [confirmDialog, setConfirmDialog] = useState(null) + const [passwordResetUser, setPasswordResetUser] = useState(null) + const [newPassword, setNewPassword] = useState('') + const [resettingPassword, setResettingPassword] = useState(false) const fetchUsers = useCallback(async () => { setLoading(true) @@ -594,14 +761,34 @@ export default function Users() { }) } + const handleResetPassword = (u) => { + setPasswordResetUser(u) + setNewPassword('') + } + + const confirmResetPassword = async () => { + if (!passwordResetUser || newPassword.length < 8) return + setResettingPassword(true) + try { + await adminUsersApi.resetPassword(passwordResetUser.id, newPassword) + addToast(`Password reset for ${passwordResetUser.name || passwordResetUser.email}`, 'success') + setPasswordResetUser(null) + setNewPassword('') + } catch (err) { + addToast(`Failed to reset password: ${err.message}`, 'error') + } finally { + setResettingPassword(false) + } + } + const filtered = users.filter(u => { if (!search) return true const q = search.toLowerCase() return (u.name || '').toLowerCase().includes(q) || (u.email || '').toLowerCase().includes(q) }) - const handlePermissionSave = (userId, newPerms, newModels) => { - setUsers(prev => prev.map(u => u.id === userId ? { ...u, permissions: newPerms, allowed_models: newModels } : u)) + const handlePermissionSave = (userId, newPerms, newModels, newQuotas) => { + setUsers(prev => prev.map(u => u.id === userId ? { ...u, permissions: newPerms, allowed_models: newModels, quotas: newQuotas } : u)) } const isSelf = (u) => currentUser && (u.id === currentUser.id || u.email === currentUser.email) @@ -727,6 +914,15 @@ export default function Users() { > + {(!u.provider || u.provider === 'local') && ( + + )} + +
+
+ + )} fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/models`, { method: 'PUT', body: JSON.stringify(allowlist), headers: { 'Content-Type': 'application/json' }, }), + getQuotas: (id) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/quotas`), + setQuota: (id, quota) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/quotas`, { + method: 'PUT', body: JSON.stringify(quota), headers: { 'Content-Type': 'application/json' }, + }), + deleteQuota: (id, quotaId) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/quotas/${encodeURIComponent(quotaId)}`, { + method: 'DELETE', + }), + resetPassword: (id, password) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/password`, { + method: 'PUT', body: JSON.stringify({ password }), headers: { 'Content-Type': 'application/json' }, + }), } // Profile API diff --git a/core/http/routes/auth.go b/core/http/routes/auth.go index 16a2e95fc0fa..376d06b0b81a 100644 --- a/core/http/routes/auth.go +++ b/core/http/routes/auth.go @@ -496,7 +496,7 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) } - return c.JSON(http.StatusOK, map[string]interface{}{ + resp := map[string]interface{}{ "id": user.ID, "email": user.Email, "name": user.Name, @@ -504,7 +504,24 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) { "role": user.Role, "provider": user.Provider, "permissions": auth.GetPermissionMapForUser(db, user), - }) + } + if quotas, err := auth.GetQuotaStatuses(db, user.ID); err == nil { + resp["quotas"] = quotas + } + return c.JSON(http.StatusOK, resp) + }) + + // GET /api/auth/quota - view own quota status + e.GET("/api/auth/quota", func(c echo.Context) error { + user := auth.GetUser(c) + if user == nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) + } + quotas, err := auth.GetQuotaStatuses(db, user.ID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get quota status"}) + } + return c.JSON(http.StatusOK, map[string]interface{}{"quotas": quotas}) }) // PUT /api/auth/profile - update user profile (name, avatar_url) @@ -805,6 +822,9 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) { } entry["permissions"] = auth.GetPermissionMapForUser(db, &u) entry["allowed_models"] = auth.GetModelAllowlist(db, u.ID) + if quotas, err := auth.GetQuotaStatuses(db, u.ID); err == nil && len(quotas) > 0 { + entry["quotas"] = quotas + } result = append(result, entry) } @@ -859,6 +879,49 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) { return c.JSON(http.StatusOK, map[string]string{"message": "status updated"}) }, adminMw) + // PUT /api/auth/admin/users/:id/password - admin reset user password + e.PUT("/api/auth/admin/users/:id/password", func(c echo.Context) error { + currentUser := auth.GetUser(c) + targetID := c.Param("id") + + if currentUser.ID == targetID { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot reset your own password via this endpoint, use self-service password change"}) + } + + var target auth.User + if err := db.First(&target, "id = ?", targetID).Error; err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) + } + + if target.Provider != auth.ProviderLocal { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "password reset is only available for local accounts"}) + } + + var body struct { + Password string `json:"password"` + } + if err := c.Bind(&body); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + + if len(body.Password) < 8 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "password must be at least 8 characters"}) + } + + hash, err := auth.HashPassword(body.Password) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"}) + } + + if err := db.Model(&auth.User{}).Where("id = ?", targetID).Update("password_hash", hash).Error; err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update password"}) + } + + auth.DeleteUserSessions(db, targetID) + + return c.JSON(http.StatusOK, map[string]string{"message": "password reset successfully"}) + }, adminMw) + // DELETE /api/auth/admin/users/:id - delete user e.DELETE("/api/auth/admin/users/:id", func(c echo.Context) error { currentUser := auth.GetUser(c) @@ -942,6 +1005,67 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) { }) }, adminMw) + // GET /api/auth/admin/users/:id/quotas - list user's quota rules + e.GET("/api/auth/admin/users/:id/quotas", func(c echo.Context) error { + targetID := c.Param("id") + var target auth.User + if err := db.First(&target, "id = ?", targetID).Error; err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) + } + quotas, err := auth.GetQuotaStatuses(db, targetID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get quotas"}) + } + return c.JSON(http.StatusOK, map[string]interface{}{"quotas": quotas}) + }, adminMw) + + // PUT /api/auth/admin/users/:id/quotas - upsert quota rule (by user+model) + e.PUT("/api/auth/admin/users/:id/quotas", func(c echo.Context) error { + targetID := c.Param("id") + var target auth.User + if err := db.First(&target, "id = ?", targetID).Error; err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) + } + + var body struct { + Model string `json:"model"` + MaxRequests *int64 `json:"max_requests"` + MaxTotalTokens *int64 `json:"max_total_tokens"` + Window string `json:"window"` + } + if err := c.Bind(&body); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + } + if body.Window == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "window is required"}) + } + + windowSecs, err := auth.ParseWindowDuration(body.Window) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + rule, err := auth.CreateOrUpdateQuotaRule(db, targetID, body.Model, body.MaxRequests, body.MaxTotalTokens, windowSecs) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save quota rule"}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "message": "quota rule saved", + "quota": rule, + }) + }, adminMw) + + // DELETE /api/auth/admin/users/:id/quotas/:quota_id - delete a quota rule + e.DELETE("/api/auth/admin/users/:id/quotas/:quota_id", func(c echo.Context) error { + targetID := c.Param("id") + quotaID := c.Param("quota_id") + if err := auth.DeleteQuotaRule(db, quotaID, targetID); err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "quota rule not found"}) + } + return c.JSON(http.StatusOK, map[string]string{"message": "quota rule deleted"}) + }, adminMw) + // GET /api/auth/admin/usage - all users' usage (admin only) e.GET("/api/auth/admin/usage", func(c echo.Context) error { period := c.QueryParam("period") From 5176ae72671ad22ed62246dd09b4bbfe91ac3dec Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 21 Mar 2026 09:08:45 +0000 Subject: [PATCH 2/2] Fix tests Signed-off-by: Ettore Di Giacinto --- core/http/react-ui/e2e/navigation.spec.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/http/react-ui/e2e/navigation.spec.js b/core/http/react-ui/e2e/navigation.spec.js index d61b290af397..5de6d970001f 100644 --- a/core/http/react-ui/e2e/navigation.spec.js +++ b/core/http/react-ui/e2e/navigation.spec.js @@ -14,6 +14,9 @@ test.describe('Navigation', () => { test('sidebar traces link navigates to /app/traces', async ({ page }) => { await page.goto('/app') + // Expand the "System" collapsible section so the traces link is visible + const systemSection = page.locator('button.sidebar-section-toggle', { hasText: 'System' }) + await systemSection.click() const tracesLink = page.locator('a.nav-item[href="/app/traces"]') await expect(tracesLink).toBeVisible() await tracesLink.click()