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 += `