From 5462030cded4d7345d6cf5298258f0e62a06c6ba Mon Sep 17 00:00:00 2001 From: Lucas Daniel <94806303+NoodleLDS@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:52:39 -0300 Subject: [PATCH] fix(auth): per-user allowed-models checklist ignores cache, [None] doesn't block (#3355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues combined to make the per-user 'Allowed models' checklist unreliable (#3032): 1. admin.js _loadModelsForUser fetched /api/models, which is backed by cached_models — endpoints that haven't been probed yet (e.g. a freshly-added DeepSeek API endpoint) simply didn't show up in the checklist. Switched to /api/model-endpoints, which always reflects every configured endpoint regardless of cache state. 2. _saveModels sent allowed_models: [] both when the admin clicked [All] (no restriction) and [None] (block everything) — the backend had no way to distinguish the two. 3. _enforce_chat_privileges treated an empty allowed_models list as 'no restriction' (falsy -> skip the check), so [None] had no effect. Added an explicit block_all_models privilege flag (defaulting to False, and forced to False for admins) that admin.js now sets when zero models are checked. _enforce_chat_privileges checks it first and 403s regardless of allowed_models contents. --- core/auth.py | 9 ++++++ routes/chat_helpers.py | 8 ++++++ static/js/admin.js | 53 +++++++++++++++++++++++++--------- tests/test_chat_helpers.py | 58 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 14 deletions(-) diff --git a/core/auth.py b/core/auth.py index 80fce1825..5db2fed4c 100644 --- a/core/auth.py +++ b/core/auth.py @@ -31,11 +31,20 @@ DEFAULT_PRIVILEGES = { "max_messages_per_day": 0, "allowed_models": [], "allowed_models_restricted": False, + # Explicit "block every model" sentinel. An empty `allowed_models` list is + # ambiguous — it's also what gets sent when the admin clicks "[All]" — so + # we need a dedicated flag to express "this user may use no models at all" + # distinctly from "this user has no restriction". + "block_all_models": False, } # Admins get everything ADMIN_PRIVILEGES = {k: (True if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()} ADMIN_PRIVILEGES["allowed_models_restricted"] = False +# Admins must never be blocked from using models — the generic dict +# comprehension above flips every boolean default to True, which would be +# backwards for this sentinel. +ADMIN_PRIVILEGES["block_all_models"] = False from src.constants import AUTH_FILE DEFAULT_AUTH_PATH = AUTH_FILE diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index 5c04ab70e..0b1c5d8ba 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -88,6 +88,14 @@ def _enforce_chat_privileges(request, sess) -> None: return privs = auth_manager.get_privileges(user) or {} + + # Explicit "block everything" sentinel takes precedence over the + # allowlist — it's the only way to distinguish "user clicked [None]" + # (block all) from "user clicked [All]" (no restriction), since both + # otherwise produce an empty `allowed_models` list. + if privs.get("block_all_models"): + raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.") + allowed_raw = privs.get("allowed_models") allowed = allowed_raw if isinstance(allowed_raw, list) else [] restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed) diff --git a/static/js/admin.js b/static/js/admin.js index 4c1add6ed..e4a39adf3 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -93,6 +93,7 @@ async function loadUsers() { : []; const allowedSet = new Set(allowedModels); const modelsRestricted = !!(u.privileges && u.privileges.allowed_models_restricted); + const blockAllModels = !!(u.privileges && u.privileges.block_all_models); html += `
Allowed models @@ -101,7 +102,7 @@ async function loadUsers() { None
-
${!modelsRestricted ? 'All models allowed (no restrictions)' : (allowedSet.size === 0 ? 'No models allowed' : allowedSet.size + ' model(s) allowed')}
+
${blockAllModels ? 'No models allowed' : (!modelsRestricted ? 'All models allowed (no restrictions)' : (allowedSet.size === 0 ? 'No models allowed' : allowedSet.size + ' model(s) allowed'))}
Loading models...
@@ -123,7 +124,7 @@ async function loadUsers() { // Load models list on first expand if (!_modelsLoaded && !privPanel.classList.contains('hidden')) { _modelsLoaded = true; - _loadModelsForUser(u.username, allowedSet, modelsRestricted, privPanel); + _loadModelsForUser(u.username, allowedSet, modelsRestricted, blockAllModels, privPanel); } }); @@ -203,17 +204,22 @@ async function loadUsers() { } catch (e) { list.innerHTML = '
Failed to load users
'; } } -async function _loadModelsForUser(username, allowedSet, modelsRestricted, privPanel) { +async function _loadModelsForUser(username, allowedSet, modelsRestricted, blockAllModels, privPanel) { const listEl = privPanel.querySelector(`.priv-models-list[data-user="${username}"]`); if (!listEl) return; try { - const res = await fetch('/api/models', { credentials: 'same-origin' }); + // Use /api/model-endpoints rather than /api/models — the latter is + // backed by `cached_models`, so endpoints that haven't been probed yet + // (e.g. a freshly-added cloud API like DeepSeek) simply don't show up + // until some other endpoint happens to trigger a cache refresh. The + // endpoints listing always reflects every configured endpoint. + const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); const data = await res.json(); const allModels = []; - (data.items || []).forEach(item => { - if (item.offline) return; - (item.models || []).forEach(mid => { - allModels.push({ mid, epName: item.endpoint_name || '', display: mid.split('/').pop() }); + (Array.isArray(data) ? data : []).forEach(ep => { + if (!ep.online) return; + (ep.models || []).forEach(mid => { + allModels.push({ mid, epName: ep.name || '', display: mid.split('/').pop() }); }); }); if (!allModels.length) { @@ -221,8 +227,9 @@ async function _loadModelsForUser(username, allowedSet, modelsRestricted, privPa return; } let restricted = modelsRestricted; + let blockAll = blockAllModels; listEl.innerHTML = sortModelObjects(allModels).map(m => { - const checked = !restricted || allowedSet.has(m.mid) ? 'checked' : ''; + const checked = !blockAll && (!restricted || allowedSet.has(m.mid)) ? 'checked' : ''; return `