From 9886ebb97f1004ebcea764af84b4c78201b15c25 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:18:14 -0500 Subject: [PATCH 01/32] fix: resolve knowledge collection indentation/truncation issue by correcting flex layout (#21374) --- .../chat/MessageInput/InputMenu/Knowledge.svelte | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte b/src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte index 249b2a2128..2df974c83d 100644 --- a/src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte @@ -186,12 +186,16 @@ }} data-selected={idx === selectedIdx} > -
+
- +
{decodeString(item?.name)}
From 4d5b7b3014c79b1705abff795da1618700ab0401 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:18:35 -0500 Subject: [PATCH 02/32] fix: resolve knowledge tooltip z-index issue in model edit page (#21375) --- .../Models/Knowledge/KnowledgeSelector.svelte | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/lib/components/workspace/Models/Knowledge/KnowledgeSelector.svelte b/src/lib/components/workspace/Models/Knowledge/KnowledgeSelector.svelte index 2d6eb0d114..23d8b9c58e 100644 --- a/src/lib/components/workspace/Models/Knowledge/KnowledgeSelector.svelte +++ b/src/lib/components/workspace/Models/Knowledge/KnowledgeSelector.svelte @@ -170,15 +170,27 @@ >
{#if item.type === 'note'} - + {:else if item.type === 'collection'} - + {:else if item.type === 'file'} - + {/if} @@ -186,6 +198,7 @@
{decodeString(item?.name)} From ddcec9842f195018f5ddbd0599f8667b35168019 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:18:58 -0500 Subject: [PATCH 03/32] fix: ensure sync modal only triggers if community sharing is enabled (#21376) --- src/routes/+layout.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6c4d0e523c..66f66676f5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -849,7 +849,11 @@ } // Auto-show SyncStatsModal when opened with ?sync=true (from community) - if ((window.opener ?? false) && $page.url.searchParams.get('sync') === 'true') { + if ( + (window.opener ?? false) && + $page.url.searchParams.get('sync') === 'true' && + ($config?.features?.enable_community_sharing ?? false) + ) { showSyncStatsModal = true; } From 7bda6bf767d5d5c4dc1111465096a88e10b5030e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 13 Feb 2026 11:20:26 -0600 Subject: [PATCH 04/32] fix: PostgreSQL cannot use get_chat_ids_by_model_id Co-Authored-By: EntropyYue <164553692+EntropyYue@users.noreply.github.com> --- backend/open_webui/models/chat_messages.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index fe3539f9cd..e6e932be12 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -15,6 +15,7 @@ from sqlalchemy import ( Text, JSON, Index, + func, ) #################### @@ -279,25 +280,26 @@ class ChatMessageTable: db: Optional[Session] = None, ) -> list[str]: """Get distinct chat_ids that used a specific model.""" - from sqlalchemy import distinct with get_db_context(db) as db: - query = db.query(distinct(ChatMessage.chat_id)).filter( - ChatMessage.model_id == model_id - ) + query = db.query( + ChatMessage.chat_id, + func.max(ChatMessage.created_at).label("last_message_at"), + ).filter(ChatMessage.model_id == model_id) if start_date: query = query.filter(ChatMessage.created_at >= start_date) if end_date: query = query.filter(ChatMessage.created_at <= end_date) - # Order by most recent message in each chat + # Group by chat_id and order by most recent message in each chat chat_ids = ( - query.order_by(ChatMessage.created_at.desc()) + query.group_by(ChatMessage.chat_id) + .order_by(func.max(ChatMessage.created_at).desc()) .offset(skip) .limit(limit) .all() ) - return [chat_id for (chat_id,) in chat_ids] + return [chat_id for chat_id, _ in chat_ids] def delete_messages_by_chat_id( self, chat_id: str, db: Optional[Session] = None From 73776d54b890271dbb4efb90f01ecb80867de32d Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:22:32 +0100 Subject: [PATCH 05/32] fix: enforce public sharing permission checks across all resource types (#21358) The sharePublic prop in editor components (Knowledge, Tools, Skills, Prompts, Models) incorrectly included an "|| edit" / "|| write_access" condition, allowing users with write access to see and use the "Public" sharing option regardless of their actual public sharing permission. Additionally, all backend access/update endpoints only verified write authorization but did not check the corresponding sharing.public_* permission, allowing direct API calls to bypass frontend restrictions entirely. Frontend: removed the edit/write_access bypass from sharePublic in all five editor components so visibility is gated solely by the user's sharing.public_* permission or admin role. Backend: added has_public_read_access_grant checks to the access/update endpoints in knowledge.py, tools.py, prompts.py, skills.py, models.py, and notes.py. Public grants are silently stripped when the user lacks the corresponding permission. Fixes #21356 --- backend/open_webui/routers/knowledge.py | 19 +++++++++++++++++ backend/open_webui/routers/models.py | 21 ++++++++++++++++++- backend/open_webui/routers/notes.py | 18 ++++++++++++++++ backend/open_webui/routers/prompts.py | 21 ++++++++++++++++++- backend/open_webui/routers/skills.py | 21 ++++++++++++++++++- backend/open_webui/routers/tools.py | 21 ++++++++++++++++++- .../workspace/Knowledge/KnowledgeBase.svelte | 3 +-- .../workspace/Models/ModelEditor.svelte | 2 +- .../workspace/Prompts/PromptEditor.svelte | 2 +- .../workspace/Skills/SkillEditor.svelte | 2 +- .../workspace/Tools/ToolkitEditor.svelte | 2 +- 11 files changed, 122 insertions(+), 10 deletions(-) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index eab00aa19b..e60c30cb62 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -511,6 +511,7 @@ class KnowledgeAccessGrantsForm(BaseModel): @router.post("/{id}/access/update", response_model=Optional[KnowledgeFilesResponse]) async def update_knowledge_access_by_id( + request: Request, id: str, form_data: KnowledgeAccessGrantsForm, user=Depends(get_verified_user), @@ -539,6 +540,24 @@ async def update_knowledge_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_knowledge", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + g for g in form_data.access_grants + if not ( + g.get("principal_type") == "user" + and g.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db) return KnowledgeFilesResponse( diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 7202262bbd..666b7ced29 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -15,7 +15,7 @@ from open_webui.models.models import ( ModelAccessResponse, Models, ) -from open_webui.models.access_grants import AccessGrants +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant from pydantic import BaseModel from open_webui.constants import ERROR_MESSAGES @@ -506,6 +506,7 @@ class ModelAccessGrantsForm(BaseModel): @router.post("/model/access/update", response_model=Optional[ModelModel]) async def update_model_access_by_id( + request: Request, form_data: ModelAccessGrantsForm, user=Depends(get_verified_user), db: Session = Depends(get_session), @@ -533,6 +534,24 @@ async def update_model_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_models", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + g for g in form_data.access_grants + if not ( + g.get("principal_type") == "user" + and g.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants( "model", form_data.id, form_data.access_grants, db=db ) diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 04841e87cc..0c4ca0d2e3 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -345,6 +345,24 @@ async def update_note_access_by_id( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_notes", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + g for g in form_data.access_grants + if not ( + g.get("principal_type") == "user" + and g.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db) return Notes.get_note_by_id(id, db=db) diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index e8d4660f03..8720ba1aa5 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -9,7 +9,7 @@ from open_webui.models.prompts import ( PromptModel, Prompts, ) -from open_webui.models.access_grants import AccessGrants +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant from open_webui.models.groups import Groups from open_webui.models.prompt_history import ( PromptHistories, @@ -436,6 +436,7 @@ class PromptAccessGrantsForm(BaseModel): @router.post("/id/{prompt_id}/access/update", response_model=Optional[PromptModel]) async def update_prompt_access_by_id( + request: Request, prompt_id: str, form_data: PromptAccessGrantsForm, user=Depends(get_verified_user), @@ -464,6 +465,24 @@ async def update_prompt_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_prompts", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + g for g in form_data.access_grants + if not ( + g.get("principal_type") == "user" + and g.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db) return Prompts.get_prompt_by_id(prompt_id, db=db) diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 367768e61b..a9a17f147a 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -17,7 +17,7 @@ from open_webui.models.skills import ( SkillAccessListResponse, Skills, ) -from open_webui.models.access_grants import AccessGrants +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission @@ -312,6 +312,7 @@ class SkillAccessGrantsForm(BaseModel): @router.post("/id/{id}/access/update", response_model=Optional[SkillModel]) async def update_skill_access_by_id( + request: Request, id: str, form_data: SkillAccessGrantsForm, user=Depends(get_verified_user), @@ -340,6 +341,24 @@ async def update_skill_access_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_skills", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + g for g in form_data.access_grants + if not ( + g.get("principal_type") == "user" + and g.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db) return Skills.get_skill_by_id(id, db=db) diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 057eb509a1..81194c2f4d 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -21,7 +21,7 @@ from open_webui.models.tools import ( ToolAccessResponse, Tools, ) -from open_webui.models.access_grants import AccessGrants +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant from open_webui.utils.plugin import ( load_tool_module_by_id, replace_imports, @@ -526,6 +526,7 @@ class ToolAccessGrantsForm(BaseModel): @router.post("/id/{id}/access/update", response_model=Optional[ToolModel]) async def update_tool_access_by_id( + request: Request, id: str, form_data: ToolAccessGrantsForm, user=Depends(get_verified_user), @@ -554,6 +555,24 @@ async def update_tool_access_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_tools", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + g for g in form_data.access_grants + if not ( + g.get("principal_type") == "user" + and g.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db) return Tools.get_tool_by_id(id, db=db) diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte index 50b4430332..58bb6203c5 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte @@ -837,8 +837,7 @@ bind:accessGrants={knowledge.access_grants} share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_knowledge || - $user?.role === 'admin' || - knowledge?.write_access} + $user?.role === 'admin'} onChange={async () => { try { await updateKnowledgeAccessGrants(localStorage.token, id, knowledge.access_grants ?? []); diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 05d17ddaf6..80abd65445 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -342,7 +342,7 @@ bind:accessGrants accessRoles={preset ? ['read', 'write'] : ['read']} share={$user?.permissions?.sharing?.models || $user?.role === 'admin'} - sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin' || edit} + sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'} onChange={async () => { if (edit && model?.id) { try { diff --git a/src/lib/components/workspace/Prompts/PromptEditor.svelte b/src/lib/components/workspace/Prompts/PromptEditor.svelte index 288d674c0d..118575d1f4 100644 --- a/src/lib/components/workspace/Prompts/PromptEditor.svelte +++ b/src/lib/components/workspace/Prompts/PromptEditor.svelte @@ -282,7 +282,7 @@ bind:accessGrants accessRoles={['read', 'write']} share={$user?.permissions?.sharing?.prompts || $user?.role === 'admin'} - sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin' || edit} + sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'} onChange={async () => { if (edit && prompt?.id) { try { diff --git a/src/lib/components/workspace/Skills/SkillEditor.svelte b/src/lib/components/workspace/Skills/SkillEditor.svelte index 1e63ff0c08..2670337b00 100644 --- a/src/lib/components/workspace/Skills/SkillEditor.svelte +++ b/src/lib/components/workspace/Skills/SkillEditor.svelte @@ -113,7 +113,7 @@ bind:accessGrants accessRoles={['read', 'write']} share={$user?.permissions?.sharing?.skills || $user?.role === 'admin'} - sharePublic={$user?.permissions?.sharing?.public_skills || $user?.role === 'admin' || edit} + sharePublic={$user?.permissions?.sharing?.public_skills || $user?.role === 'admin'} onChange={async () => { if (edit && skill?.id) { try { diff --git a/src/lib/components/workspace/Tools/ToolkitEditor.svelte b/src/lib/components/workspace/Tools/ToolkitEditor.svelte index 4822ebbf8c..c73d47a793 100644 --- a/src/lib/components/workspace/Tools/ToolkitEditor.svelte +++ b/src/lib/components/workspace/Tools/ToolkitEditor.svelte @@ -192,7 +192,7 @@ class Tools: bind:accessGrants accessRoles={['read', 'write']} share={$user?.permissions?.sharing?.tools || $user?.role === 'admin'} - sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin' || edit} + sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin'} onChange={async () => { if (edit && id) { try { From b4c3f54f9648c4232a0fd6557703ffa66fcf4caa Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 13 Feb 2026 11:24:08 -0600 Subject: [PATCH 06/32] fix: skills postgres issue --- backend/open_webui/models/skills.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index 71e8f97b31..1262830153 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -3,13 +3,13 @@ import time from typing import Optional from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from open_webui.internal.db import Base, get_db, get_db_context from open_webui.models.users import Users, UserResponse from open_webui.models.groups import Groups from open_webui.models.access_grants import AccessGrantModel, AccessGrants from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import BigInteger, Boolean, Column, String, Text, or_ +from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, or_ log = logging.getLogger(__name__) @@ -26,7 +26,7 @@ class Skill(Base): name = Column(Text, unique=True) description = Column(Text, nullable=True) content = Column(Text) - meta = Column(JSONField) + meta = Column(JSON) is_active = Column(Boolean, default=True) updated_at = Column(BigInteger) From df6e38039f14d45764142b9d60082bb815e11d57 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 13 Feb 2026 13:29:22 -0600 Subject: [PATCH 07/32] refac --- CHANGELOG.md | 12 ++++++++++++ backend/open_webui/routers/knowledge.py | 8 +++----- backend/open_webui/routers/models.py | 8 +++----- backend/open_webui/routers/notes.py | 8 +++----- backend/open_webui/routers/ollama.py | 19 ------------------- backend/open_webui/routers/prompts.py | 8 +++----- backend/open_webui/routers/skills.py | 8 +++----- backend/open_webui/routers/tools.py | 8 +++----- package-lock.json | 4 ++-- package.json | 2 +- .../workspace/Knowledge/KnowledgeBase.svelte | 3 +-- 11 files changed, 34 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 955c1f066e..ff30a13565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.1] - 2026-02-13 + +### Fixed + +- πŸ”’ **Public sharing permission bypass fix.** Users with write access to knowledge bases, tools, skills, prompts, and models could previously see and use the "Public" sharing option regardless of their actual sharing permissions, and direct API calls could bypass frontend restrictions entirely; both frontend and backend now properly enforce `sharing.public_*` permissions, silently stripping public grants when users lack the corresponding permission. [#21356](https://github.com/open-webui/open-webui/issues/21356), [#21358](https://github.com/open-webui/open-webui/pull/21358) +- 🐘 **PostgreSQL analytics query fix.** The `get_chat_ids_by_model_id` function now works correctly with PostgreSQL by using `GROUP BY` with aggregate ordering instead of `DISTINCT` with non-aggregate `ORDER BY`, which PostgreSQL does not support. [Commit](https://github.com/open-webui/open-webui/commit/7bda6bf767d5d5c4dc1111465096a88e10b5030e) +- 🐘 **Skills PostgreSQL compatibility fix.** Skills now work correctly with PostgreSQL by using SQLAlchemy's native `JSON` column type instead of the custom `JSONField`, which caused compatibility issues. [Commit](https://github.com/open-webui/open-webui/commit/b4c3f54f9648c4232a0fd6557703ffa66fcf4caa) +- πŸ” **Knowledge tooltip z-index fix.** Knowledge base tooltips in the model editor no longer render behind other UI elements. [#21375](https://github.com/open-webui/open-webui/pull/21375) +- πŸ“š **Knowledge collection layout fix.** Knowledge collection names in the chat input menu no longer appear indented or truncated due to incorrect flex layout. [#21374](https://github.com/open-webui/open-webui/pull/21374) +- 🎯 **Model selector scroll position fix.** The model selector dropdown now correctly scrolls to and centers the currently selected model when opened, and resets scroll position when reopened. [Commit](https://github.com/open-webui/open-webui/commit/0b05b2fc7ed4c38af158707438ff404d1beb7c91) +- 🌐 **Translation updates.** Portuguese (Brazil) translations were updated. [#21345](https://github.com/open-webui/open-webui/pull/21345) + ## [0.8.0] - 2026-02-12 ### Added diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index e60c30cb62..d4b1e0a803 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -551,11 +551,9 @@ async def update_knowledge_access_by_id( ) ): form_data.access_grants = [ - g for g in form_data.access_grants - if not ( - g.get("principal_type") == "user" - and g.get("principal_id") == "*" - ) + grant + for grant in form_data.access_grants + if not (grant.get("principal_type") == "user" and grant.get("principal_id") == "*") ] AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 666b7ced29..4befd4c200 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -545,11 +545,9 @@ async def update_model_access_by_id( ) ): form_data.access_grants = [ - g for g in form_data.access_grants - if not ( - g.get("principal_type") == "user" - and g.get("principal_id") == "*" - ) + grant + for grant in form_data.access_grants + if not (grant.get("principal_type") == "user" and grant.get("principal_id") == "*") ] AccessGrants.set_access_grants( diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 0c4ca0d2e3..cba4c3f4ca 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -356,11 +356,9 @@ async def update_note_access_by_id( ) ): form_data.access_grants = [ - g for g in form_data.access_grants - if not ( - g.get("principal_type") == "user" - and g.get("principal_id") == "*" - ) + grant + for grant in form_data.access_grants + if not (grant.get("principal_type") == "user" and grant.get("principal_id") == "*") ] AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index fea2497561..7356a2b0ba 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -653,10 +653,6 @@ async def unload_model( await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS - # Canonicalize model name (if not supplied with version) - if ":" not in model_name: - model_name = f"{model_name}:latest" - if model_name not in models: raise HTTPException( status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name) @@ -1353,9 +1349,6 @@ async def generate_chat_completion( detail="Model not found", ) - if ":" not in payload["model"]: - payload["model"] = f"{payload['model']}:latest" - url, url_idx = await get_ollama_url(request, payload["model"], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(url_idx), @@ -1432,9 +1425,6 @@ async def generate_openai_completion( del payload["metadata"] model_id = form_data.model - if ":" not in model_id: - model_id = f"{model_id}:latest" - model_info = Models.get_model_by_id(model_id) if model_info: if model_info.base_model_id: @@ -1466,9 +1456,6 @@ async def generate_openai_completion( detail="Model not found", ) - if ":" not in payload["model"]: - payload["model"] = f"{payload['model']}:latest" - url, url_idx = await get_ollama_url(request, payload["model"], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(url_idx), @@ -1518,9 +1505,6 @@ async def generate_openai_chat_completion( del payload["metadata"] model_id = completion_form.model - if ":" not in model_id: - model_id = f"{model_id}:latest" - model_info = Models.get_model_by_id(model_id) if model_info: if model_info.base_model_id: @@ -1556,9 +1540,6 @@ async def generate_openai_chat_completion( detail="Model not found", ) - if ":" not in payload["model"]: - payload["model"] = f"{payload['model']}:latest" - url, url_idx = await get_ollama_url(request, payload["model"], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(url_idx), diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 8720ba1aa5..4d0bd07d0a 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -476,11 +476,9 @@ async def update_prompt_access_by_id( ) ): form_data.access_grants = [ - g for g in form_data.access_grants - if not ( - g.get("principal_type") == "user" - and g.get("principal_id") == "*" - ) + grant + for grant in form_data.access_grants + if not (grant.get("principal_type") == "user" and grant.get("principal_id") == "*") ] AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index a9a17f147a..2a51b993c8 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -352,11 +352,9 @@ async def update_skill_access_by_id( ) ): form_data.access_grants = [ - g for g in form_data.access_grants - if not ( - g.get("principal_type") == "user" - and g.get("principal_id") == "*" - ) + grant + for grant in form_data.access_grants + if not (grant.get("principal_type") == "user" and grant.get("principal_id") == "*") ] AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 81194c2f4d..60fecbb6fc 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -566,11 +566,9 @@ async def update_tool_access_by_id( ) ): form_data.access_grants = [ - g for g in form_data.access_grants - if not ( - g.get("principal_type") == "user" - and g.get("principal_id") == "*" - ) + grant + for grant in form_data.access_grants + if not (grant.get("principal_type") == "user" and grant.get("principal_id") == "*") ] AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db) diff --git a/package-lock.json b/package-lock.json index 0a85159d06..b837771419 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.8.0", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index 673bad49cc..a6e16abfa6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.8.0", + "version": "0.8.1", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte index 58bb6203c5..4783530777 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte @@ -836,8 +836,7 @@ bind:show={showAccessControlModal} bind:accessGrants={knowledge.access_grants} share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'} - sharePublic={$user?.permissions?.sharing?.public_knowledge || - $user?.role === 'admin'} + sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'} onChange={async () => { try { await updateKnowledgeAccessGrants(localStorage.token, id, knowledge.access_grants ?? []); From d01b1d48809c105b31a972d47b47808dabb98999 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:32:48 +0100 Subject: [PATCH 08/32] enh: apply admin default to builtin web search (#21373) --- backend/open_webui/tools/builtin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 0175ba5583..cec0375ab7 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -167,6 +167,9 @@ async def search_web( engine = __request__.app.state.config.WEB_SEARCH_ENGINE user = UserModel(**__user__) if __user__ else None + # Use admin-configured result count if configured, falling back to model-provided count of provided, else default to 5 + count = __request__.app.state.config.WEB_SEARCH_RESULT_COUNT or count + results = await asyncio.to_thread(_search_web, __request__, engine, query, user) # Limit results From 97a3b1528de7689fc8fa9bd90ac017ce384abc63 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:37:12 +0100 Subject: [PATCH 09/32] Update utils.py (#21105) --- backend/open_webui/retrieval/vector/utils.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/open_webui/retrieval/vector/utils.py b/backend/open_webui/retrieval/vector/utils.py index a597390b92..a39d364419 100644 --- a/backend/open_webui/retrieval/vector/utils.py +++ b/backend/open_webui/retrieval/vector/utils.py @@ -4,6 +4,7 @@ KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figu def filter_metadata(metadata: dict[str, any]) -> dict[str, any]: + # Removes large/redundant fields from metadata dict. metadata = { key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE } @@ -13,16 +14,15 @@ def filter_metadata(metadata: dict[str, any]) -> dict[str, any]: def process_metadata( metadata: dict[str, any], ) -> dict[str, any]: + # Removes large fields and converts non-serializable types (datetime, list, dict) to strings. + result = {} for key, value in metadata.items(): - # Remove large fields + # Skip large fields if key in KEYS_TO_EXCLUDE: - del metadata[key] - + continue # Convert non-serializable fields to strings - if ( - isinstance(value, datetime) - or isinstance(value, list) - or isinstance(value, dict) - ): - metadata[key] = str(value) - return metadata + if isinstance(value, (datetime, list, dict)): + result[key] = str(value) + else: + result[key] = value + return result From ca6b18ab5cb94153a9dae233f975d36bf6b19b76 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 13 Feb 2026 13:40:59 -0600 Subject: [PATCH 10/32] refac: is_user_active --- backend/open_webui/models/users.py | 8 ++++++++ backend/open_webui/routers/channels.py | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 7e398c1e4a..fffeb32d3e 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -762,6 +762,14 @@ class UsersTable: ) return count + @staticmethod + def is_active(user: UserModel) -> bool: + """Compute active status from an already-loaded UserModel (no DB hit).""" + if user.last_active_at: + three_minutes_ago = int(time.time()) - 180 + return user.last_active_at >= three_minutes_ago + return False + def is_user_active(self, user_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: user = db.query(User).filter_by(id=user_id).first() diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 1748eaf7ea..3add5023e7 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -204,7 +204,7 @@ async def get_channels( UserIdNameStatusResponse( **{ **user.model_dump(), - "is_active": Users.is_user_active(user.id, db=db), + "is_active": Users.is_active(user), } ) for user in Users.get_users_by_user_ids(user_ids, db=db) @@ -424,7 +424,7 @@ async def get_channel_by_id( UserIdNameStatusResponse( **{ **user.model_dump(), - "is_active": Users.is_user_active(user.id, db=db), + "is_active": Users.is_active(user), } ) for user in Users.get_users_by_user_ids(user_ids, db=db) @@ -541,7 +541,7 @@ async def get_channel_members_by_id( return { "users": [ UserModelResponse( - **user.model_dump(), is_active=Users.is_user_active(user.id, db=db) + **user.model_dump(), is_active=Users.is_active(user) ) for user in users ], @@ -576,7 +576,7 @@ async def get_channel_members_by_id( return { "users": [ UserModelResponse( - **user.model_dump(), is_active=Users.is_user_active(user.id, db=db) + **user.model_dump(), is_active=Users.is_active(user) ) for user in users ], From 20de5a87da0c12e4052b50887a42ddd7228c5ef5 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 13 Feb 2026 13:43:43 -0600 Subject: [PATCH 11/32] refac --- backend/open_webui/routers/models.py | 7 ++++++- backend/open_webui/routers/prompts.py | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 4befd4c200..43065faa13 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -84,14 +84,18 @@ async def get_models( if direction: filter["direction"] = direction + # Pre-fetch user group IDs once - used for both filter and write_access check + groups = Groups.get_groups_by_member_id(user.id, db=db) + user_group_ids = {group.id for group in groups} + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: - groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: filter["group_ids"] = [group.id for group in groups] filter["user_id"] = user.id result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db) + return ModelAccessListResponse( items=[ ModelAccessResponse( @@ -104,6 +108,7 @@ async def get_models( resource_type="model", resource_id=model.id, permission="write", + user_group_ids=user_group_ids, db=db, ) ), diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 4d0bd07d0a..86d2648a88 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -100,8 +100,11 @@ async def get_prompt_list( if direction: filter["direction"] = direction + # Pre-fetch user group IDs once - used for both filter and write_access check + groups = Groups.get_groups_by_member_id(user.id, db=db) + user_group_ids = {group.id for group in groups} + if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL): - groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: filter["group_ids"] = [group.id for group in groups] @@ -123,6 +126,7 @@ async def get_prompt_list( resource_type="prompt", resource_id=prompt.id, permission="write", + user_group_ids=user_group_ids, db=db, ) ), From 589c4e64c1b7bb7a7a5abc20382b92fb860e28c2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 13 Feb 2026 13:56:29 -0600 Subject: [PATCH 12/32] refac --- backend/open_webui/models/access_grants.py | 56 +++++++++++++++++++++ backend/open_webui/routers/knowledge.py | 46 +++++++++++------ backend/open_webui/routers/models.py | 20 +++++--- backend/open_webui/routers/ollama.py | 58 ++++++++++++++-------- backend/open_webui/routers/openai.py | 27 ++++++---- backend/open_webui/routers/prompts.py | 20 +++++--- backend/open_webui/utils/models.py | 22 ++++---- 7 files changed, 178 insertions(+), 71 deletions(-) diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index fa6e79a8db..dd5a344b46 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -515,6 +515,62 @@ class AccessGrantsTable: ) return exists is not None + def get_accessible_resource_ids( + self, + user_id: str, + resource_type: str, + resource_ids: list[str], + permission: str = "read", + user_group_ids: Optional[set[str]] = None, + db: Optional[Session] = None, + ) -> set[str]: + """ + Batch check: return the subset of resource_ids that the user can access. + + This replaces calling has_access() in a loop (N+1) with a single query. + """ + if not resource_ids: + return set() + + with get_db_context(db) as db: + conditions = [ + and_( + AccessGrant.principal_type == "user", + AccessGrant.principal_id == "*", + ), + and_( + AccessGrant.principal_type == "user", + AccessGrant.principal_id == user_id, + ), + ] + + if user_group_ids is None: + from open_webui.models.groups import Groups + + user_groups = Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = {group.id for group in user_groups} + + if user_group_ids: + conditions.append( + and_( + AccessGrant.principal_type == "group", + AccessGrant.principal_id.in_(user_group_ids), + ) + ) + + rows = ( + db.query(AccessGrant.resource_id) + .filter( + AccessGrant.resource_type == resource_type, + AccessGrant.resource_id.in_(resource_ids), + AccessGrant.permission == permission, + or_(*conditions), + ) + .distinct() + .all() + ) + return {row[0] for row in rows} + def get_users_with_access( self, resource_type: str, diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index d4b1e0a803..d620c1745f 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -115,8 +115,10 @@ async def get_knowledge_bases( skip = (page - 1) * limit filter = {} + groups = Groups.get_groups_by_member_id(user.id, db=db) + user_group_ids = {group.id for group in groups} + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: - groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: filter["group_ids"] = [group.id for group in groups] @@ -126,6 +128,17 @@ async def get_knowledge_bases( user.id, filter=filter, skip=skip, limit=limit, db=db ) + # Batch-fetch writable knowledge IDs in a single query instead of N has_access calls + knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items] + writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="knowledge", + resource_ids=knowledge_base_ids, + permission="write", + user_group_ids=user_group_ids, + db=db, + ) + return KnowledgeAccessListResponse( items=[ KnowledgeAccessResponse( @@ -133,13 +146,7 @@ async def get_knowledge_bases( write_access=( user.id == knowledge_base.user_id or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) - or AccessGrants.has_access( - user_id=user.id, - resource_type="knowledge", - resource_id=knowledge_base.id, - permission="write", - db=db, - ) + or knowledge_base.id in writable_knowledge_base_ids ), ) for knowledge_base in result.items @@ -166,8 +173,10 @@ async def search_knowledge_bases( if view_option: filter["view_option"] = view_option + groups = Groups.get_groups_by_member_id(user.id, db=db) + user_group_ids = {group.id for group in groups} + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: - groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: filter["group_ids"] = [group.id for group in groups] @@ -177,6 +186,17 @@ async def search_knowledge_bases( user.id, filter=filter, skip=skip, limit=limit, db=db ) + # Batch-fetch writable knowledge IDs in a single query instead of N has_access calls + knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items] + writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="knowledge", + resource_ids=knowledge_base_ids, + permission="write", + user_group_ids=user_group_ids, + db=db, + ) + return KnowledgeAccessListResponse( items=[ KnowledgeAccessResponse( @@ -184,13 +204,7 @@ async def search_knowledge_bases( write_access=( user.id == knowledge_base.user_id or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) - or AccessGrants.has_access( - user_id=user.id, - resource_type="knowledge", - resource_id=knowledge_base.id, - permission="write", - db=db, - ) + or knowledge_base.id in writable_knowledge_base_ids ), ) for knowledge_base in result.items diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 43065faa13..fe4137cab9 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -96,6 +96,17 @@ async def get_models( result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db) + # Batch-fetch writable model IDs in a single query instead of N has_access calls + model_ids = [model.id for model in result.items] + writable_model_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="model", + resource_ids=model_ids, + permission="write", + user_group_ids=user_group_ids, + db=db, + ) + return ModelAccessListResponse( items=[ ModelAccessResponse( @@ -103,14 +114,7 @@ async def get_models( write_access=( (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == model.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type="model", - resource_id=model.id, - permission="write", - user_group_ids=user_group_ids, - db=db, - ) + or model.id in writable_model_ids ), ) for model in result.items diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 7356a2b0ba..394735e898 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -418,21 +418,24 @@ async def get_all_models(request: Request, user: UserModel = None): async def get_filtered_models(models, user, db=None): # Filter models based on user access control model_ids = [model["model"] for model in models.get("models", [])] - model_infos = {m.id: m for m in Models.get_models_by_ids(model_ids, db=db)} - user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)} + model_infos = {model_info.id: model_info for model_info in Models.get_models_by_ids(model_ids, db=db)} + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} + + # Batch-fetch accessible resource IDs in a single query instead of N has_access calls + accessible_model_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="model", + resource_ids=list(model_infos.keys()), + permission="read", + user_group_ids=user_group_ids, + db=db, + ) filtered_models = [] for model in models.get("models", []): model_info = model_infos.get(model["model"]) if model_info: - if user.id == model_info.user_id or AccessGrants.has_access( - user_id=user.id, - resource_type="model", - resource_id=model_info.id, - permission="read", - user_group_ids=user_group_ids, - db=db, - ): + if user.id == model_info.user_id or model_info.id in accessible_model_ids: filtered_models.append(model) return filtered_models @@ -1329,6 +1332,9 @@ async def generate_chat_completion( # Check if user has access to the model if not bypass_filter and user.role == "user": + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id) + } if not ( user.id == model_info.user_id or AccessGrants.has_access( @@ -1336,6 +1342,7 @@ async def generate_chat_completion( resource_type="model", resource_id=model_info.id, permission="read", + user_group_ids=user_group_ids, ) ): raise HTTPException( @@ -1436,6 +1443,9 @@ async def generate_openai_completion( # Check if user has access to the model if user.role == "user": + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id) + } if not ( user.id == model_info.user_id or AccessGrants.has_access( @@ -1443,6 +1453,7 @@ async def generate_openai_completion( resource_type="model", resource_id=model_info.id, permission="read", + user_group_ids=user_group_ids, ) ): raise HTTPException( @@ -1520,6 +1531,9 @@ async def generate_openai_chat_completion( # Check if user has access to the model if user.role == "user": + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id) + } if not ( user.id == model_info.user_id or AccessGrants.has_access( @@ -1527,6 +1541,7 @@ async def generate_openai_chat_completion( resource_type="model", resource_id=model_info.id, permission="read", + user_group_ids=user_group_ids, ) ): raise HTTPException( @@ -1618,21 +1633,24 @@ async def get_openai_models( if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL: # Filter models based on user access control model_ids = [model["id"] for model in models] - model_infos = {m.id: m for m in Models.get_models_by_ids(model_ids, db=db)} - user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)} + model_infos = {model_info.id: model_info for model_info in Models.get_models_by_ids(model_ids, db=db)} + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} + + # Batch-fetch accessible resource IDs in a single query instead of N has_access calls + accessible_model_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="model", + resource_ids=list(model_infos.keys()), + permission="read", + user_group_ids=user_group_ids, + db=db, + ) filtered_models = [] for model in models: model_info = model_infos.get(model["id"]) if model_info: - if user.id == model_info.user_id or AccessGrants.has_access( - user_id=user.id, - resource_type="model", - resource_id=model_info.id, - permission="read", - user_group_ids=user_group_ids, - db=db, - ): + if user.id == model_info.user_id or model_info.id in accessible_model_ids: filtered_models.append(model) models = filtered_models diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index de978d011a..f8688a9c93 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -455,21 +455,24 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: async def get_filtered_models(models, user, db=None): # Filter models based on user access control model_ids = [model["id"] for model in models.get("data", [])] - model_infos = {m.id: m for m in Models.get_models_by_ids(model_ids, db=db)} - user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)} + model_infos = {model_info.id: model_info for model_info in Models.get_models_by_ids(model_ids, db=db)} + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} + + # Batch-fetch accessible resource IDs in a single query instead of N has_access calls + accessible_model_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="model", + resource_ids=list(model_infos.keys()), + permission="read", + user_group_ids=user_group_ids, + db=db, + ) filtered_models = [] for model in models.get("data", []): model_info = model_infos.get(model["id"]) if model_info: - if user.id == model_info.user_id or AccessGrants.has_access( - user_id=user.id, - resource_type="model", - resource_id=model_info.id, - permission="read", - user_group_ids=user_group_ids, - db=db, - ): + if user.id == model_info.user_id or model_info.id in accessible_model_ids: filtered_models.append(model) return filtered_models @@ -960,6 +963,9 @@ async def generate_chat_completion( # Check if user has access to the model if not bypass_filter and user.role == "user": + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id) + } if not ( user.id == model_info.user_id or AccessGrants.has_access( @@ -967,6 +973,7 @@ async def generate_chat_completion( resource_type="model", resource_id=model_info.id, permission="read", + user_group_ids=user_group_ids, ) ): raise HTTPException( diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 86d2648a88..0060ab2b18 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -114,6 +114,17 @@ async def get_prompt_list( user.id, filter=filter, skip=skip, limit=limit, db=db ) + # Batch-fetch writable prompt IDs in a single query instead of N has_access calls + prompt_ids = [prompt.id for prompt in result.items] + writable_prompt_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="prompt", + resource_ids=prompt_ids, + permission="write", + user_group_ids=user_group_ids, + db=db, + ) + return PromptAccessListResponse( items=[ PromptAccessResponse( @@ -121,14 +132,7 @@ async def get_prompt_list( write_access=( (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == prompt.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type="prompt", - resource_id=prompt.id, - permission="write", - user_group_ids=user_group_ids, - db=db, - ) + or prompt.id in writable_prompt_ids ), ) for prompt in result.items diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index ff3a6e0caf..8bef1591bb 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -377,10 +377,21 @@ def get_filtered_models(models, user, db=None): for model_info in Models.get_models_by_ids(model_ids) } - filtered_models = [] user_group_ids = { group.id for group in Groups.get_groups_by_member_id(user.id, db=db) } + + # Batch-fetch accessible resource IDs in a single query instead of N has_access calls + accessible_model_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="model", + resource_ids=list(model_infos.keys()), + permission="read", + user_group_ids=user_group_ids, + db=db, + ) + + filtered_models = [] for model in models: if model.get("arena"): meta = model.get("info", {}).get("meta", {}) @@ -399,14 +410,7 @@ def get_filtered_models(models, user, db=None): if ( (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == model_info.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type="model", - resource_id=model_info.id, - permission="read", - user_group_ids=user_group_ids, - db=db, - ) + or model_info.id in accessible_model_ids ): filtered_models.append(model) From b7549d2f6ca2843661ec79a5a1e55da9e7553368 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 13 Feb 2026 14:08:07 -0600 Subject: [PATCH 13/32] refac: defer profile --- backend/open_webui/models/users.py | 27 +++++++++++++------ backend/open_webui/routers/auths.py | 4 +-- backend/open_webui/socket/main.py | 8 +++++- .../test/apps/webui/routers/test_users.py | 4 +-- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index fffeb32d3e..cff45ccb35 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -1,7 +1,7 @@ import time from typing import Optional -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, defer from open_webui.internal.db import Base, JSONField, get_db, get_db_context @@ -15,7 +15,7 @@ from open_webui.utils.misc import throttle from open_webui.utils.validate import validate_profile_image_url -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, field_validator, model_validator from sqlalchemy import ( BigInteger, JSON, @@ -28,7 +28,7 @@ from sqlalchemy import ( select, cast, ) -from sqlalchemy import or_, case +from sqlalchemy import or_, case, func from sqlalchemy.dialects.postgresql import JSONB import datetime @@ -86,7 +86,7 @@ class UserModel(BaseModel): name: str - profile_image_url: str + profile_image_url: Optional[str] = None profile_banner_image_url: Optional[str] = None bio: Optional[str] = None @@ -110,6 +110,12 @@ class UserModel(BaseModel): model_config = ConfigDict(from_attributes=True) + @model_validator(mode="after") + def set_profile_image_url(self): + if not self.profile_image_url: + self.profile_image_url = f"/api/v1/users/{self.id}/profile/image" + return self + class UserStatusModel(UserModel): is_active: bool = False @@ -315,8 +321,12 @@ class UsersTable: ) -> Optional[UserModel]: try: with get_db_context(db) as db: - user = db.query(User).filter_by(email=email).first() - return UserModel.model_validate(user) + user = ( + db.query(User) + .filter(func.lower(User.email) == email.lower()) + .first() + ) + return UserModel.model_validate(user) if user else None except Exception: return None @@ -350,7 +360,7 @@ class UsersTable: ) -> dict: with get_db_context(db) as db: # Join GroupMember so we can order by group_id when requested - query = db.query(User) + query = db.query(User).options(defer(User.profile_image_url)) if filter: query_key = filter.get("query") @@ -485,6 +495,7 @@ class UsersTable: with get_db_context(db) as db: users = ( db.query(User) + .options(defer(User.profile_image_url)) .join(GroupMember, User.id == GroupMember.user_id) .filter(GroupMember.group_id == group_id) .all() @@ -495,7 +506,7 @@ class UsersTable: self, user_ids: list[str], db: Optional[Session] = None ) -> list[UserStatusModel]: with get_db_context(db) as db: - users = db.query(User).filter(User.id.in_(user_ids)).all() + users = db.query(User).options(defer(User.profile_image_url)).filter(User.id.in_(user_ids)).all() return [UserModel.model_validate(user) for user in users] def get_num_users(self, db: Optional[Session] = None) -> Optional[int]: diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 7f86307e80..8ada514cc2 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -145,7 +145,7 @@ def create_session_response( "email": user.email, "name": user.name, "role": user.role, - "profile_image_url": user.profile_image_url, + "profile_image_url": f"/api/v1/users/{user.id}/profile/image", "permissions": user_permissions, } @@ -926,7 +926,7 @@ async def add_user( "email": user.email, "name": user.name, "role": user.role, - "profile_image_url": user.profile_image_url, + "profile_image_url": f"/api/v1/users/{user.id}/profile/image", } else: raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 0d762ee5b2..b43c56b4e6 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -314,7 +314,13 @@ async def connect(sid, environ, auth): if user: SESSION_POOL[sid] = user.model_dump( - exclude=["date_of_birth", "bio", "gender"] + exclude=[ + "profile_image_url", + "profile_banner_image_url", + "date_of_birth", + "bio", + "gender", + ] ) await sio.enter_room(sid, f"user:{user.id}") diff --git a/backend/open_webui/test/apps/webui/routers/test_users.py b/backend/open_webui/test/apps/webui/routers/test_users.py index 1a58ab147a..3108729710 100644 --- a/backend/open_webui/test/apps/webui/routers/test_users.py +++ b/backend/open_webui/test/apps/webui/routers/test_users.py @@ -12,7 +12,7 @@ def _assert_user(data, id, **kwargs): comparison_data = { "name": f"user {id}", "email": f"user{id}@openwebui.com", - "profile_image_url": f"/user{id}.png", + "profile_image_url": f"/api/v1/users/{id}/profile/image", "role": "user", **kwargs, } @@ -150,7 +150,7 @@ class TestUsers(AbstractPostgresTest): role="admin", name="user 2 updated", email="user2-updated@openwebui.com", - profile_image_url="/user2-updated.png", + profile_image_url=f"/api/v1/users/2/profile/image", ) # Delete user by id From d1d1efe212b16e0052359991d67fd813125077e8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 13 Feb 2026 14:27:11 -0600 Subject: [PATCH 14/32] refac: scim --- backend/open_webui/env.py | 8 ++ ...3d4e5f6a7_add_scim_column_to_user_table.py | 26 +++++ backend/open_webui/models/users.py | 59 +++++++++++ backend/open_webui/routers/scim.py | 98 +++++++++++++++++-- 4 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 backend/open_webui/migrations/versions/b2c3d4e5f6a7_add_scim_column_to_user_table.py diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 4ec2e2d8e2..9bbf0aecdd 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -572,6 +572,14 @@ ENABLE_SCIM = ( == "true" ) SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "") +SCIM_AUTH_PROVIDER = os.environ.get("SCIM_AUTH_PROVIDER", "") + +if ENABLE_SCIM and not SCIM_AUTH_PROVIDER: + log.warning( + "SCIM is enabled but SCIM_AUTH_PROVIDER is not set. " + "Set SCIM_AUTH_PROVIDER to the OAuth provider name (e.g. 'microsoft', 'oidc') " + "to enable externalId storage." + ) #################################### # LICENSE_KEY diff --git a/backend/open_webui/migrations/versions/b2c3d4e5f6a7_add_scim_column_to_user_table.py b/backend/open_webui/migrations/versions/b2c3d4e5f6a7_add_scim_column_to_user_table.py new file mode 100644 index 0000000000..e8bf9a850f --- /dev/null +++ b/backend/open_webui/migrations/versions/b2c3d4e5f6a7_add_scim_column_to_user_table.py @@ -0,0 +1,26 @@ +"""add scim column to user table + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-02-13 14:19:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "b2c3d4e5f6a7" +down_revision: Union[str, None] = "a1b2c3d4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("user", sa.Column("scim", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("user", "scim") diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index cff45ccb35..2eb76131ab 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -71,6 +71,7 @@ class User(Base): settings = Column(JSON, nullable=True) oauth = Column(JSON, nullable=True) + scim = Column(JSON, nullable=True) last_active_at = Column(BigInteger) updated_at = Column(BigInteger) @@ -103,6 +104,7 @@ class UserModel(BaseModel): settings: Optional[UserSettings] = None oauth: Optional[dict] = None + scim: Optional[dict] = None last_active_at: int # timestamp in epoch updated_at: int # timestamp in epoch @@ -351,6 +353,31 @@ class UsersTable: # You may want to log the exception here return None + def get_user_by_scim_external_id( + self, provider: str, external_id: str, db: Optional[Session] = None + ) -> Optional[UserModel]: + try: + with get_db_context(db) as db: # type: Session + dialect_name = db.bind.dialect.name + + query = db.query(User) + if dialect_name == "sqlite": + query = query.filter( + User.scim.contains( + {provider: {"external_id": external_id}} + ) + ) + elif dialect_name == "postgresql": + query = query.filter( + User.scim[provider].cast(JSONB)["external_id"].astext + == external_id + ) + + user = query.first() + return UserModel.model_validate(user) if user else None + except Exception: + return None + def get_users( self, filter: Optional[dict] = None, @@ -646,6 +673,38 @@ class UsersTable: except Exception: return None + def update_user_scim_by_id( + self, + id: str, + provider: str, + external_id: str, + db: Optional[Session] = None, + ) -> Optional[UserModel]: + """ + Update or insert a SCIM provider/external_id pair into the user's scim JSON field. + Example resulting structure: + { + "microsoft": { "external_id": "abc" }, + "okta": { "external_id": "def" } + } + """ + try: + with get_db_context(db) as db: + user = db.query(User).filter_by(id=id).first() + if not user: + return None + + scim = user.scim or {} + scim[provider] = {"external_id": external_id} + + db.query(User).filter_by(id=id).update({"scim": scim}) + db.commit() + + return UserModel.model_validate(user) + + except Exception: + return None + def update_user_by_id( self, id: str, updated: dict, db: Optional[Session] = None ) -> Optional[UserModel]: diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index 681be3c7d2..13d3b5bdf2 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -25,6 +25,9 @@ from open_webui.utils.auth import ( ) from open_webui.constants import ERROR_MESSAGES +from open_webui.config import OAUTH_PROVIDERS +from open_webui.env import SCIM_AUTH_PROVIDER + from sqlalchemy.orm import Session from open_webui.internal.db import get_session @@ -300,6 +303,45 @@ def get_scim_auth( ) +def get_external_id(user: UserModel) -> Optional[str]: + """Extract externalId from a user's scim data. + + Checks all stored provider entries and returns the first external_id found. + """ + if not user.scim: + return None + for provider_data in user.scim.values(): + if isinstance(provider_data, dict) and "external_id" in provider_data: + return provider_data["external_id"] + return None + + +def get_scim_provider() -> str: + """Return the configured SCIM auth provider. + + Requires SCIM_AUTH_PROVIDER env var to be set (e.g. 'microsoft', 'oidc'). + """ + if not SCIM_AUTH_PROVIDER: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="SCIM_AUTH_PROVIDER environment variable is required when SCIM is enabled", + ) + return SCIM_AUTH_PROVIDER + + +def find_user_by_external_id( + external_id: str, db=None +) -> Optional[UserModel]: + """Find a user by SCIM externalId, falling back to OAuth sub match.""" + provider = get_scim_provider() + user = Users.get_user_by_scim_external_id(provider, external_id, db=db) + if user: + return user + + # Fallback: check if externalId matches an existing OAuth sub (account linking) + return Users.get_user_by_oauth_sub(provider, external_id, db=db) + + def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: """Convert internal User model to SCIM User""" # Parse display name into name components @@ -321,6 +363,7 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: return SCIMUser( id=user.id, + externalId=get_external_id(user), userName=user.email, name=SCIMName( formatted=user.name, @@ -494,13 +537,17 @@ async def get_users( # Get users from database if filter: - # Simple filter parsing - supports userName eq "email" - # In production, you'd want a more robust filter parser + # Simple filter parsing - supports userName eq, externalId eq if "userName eq" in filter: email = filter.split('"')[1] user = Users.get_user_by_email(email, db=db) users_list = [user] if user else [] total = 1 if user else 0 + elif "externalId eq" in filter: + external_id = filter.split('"')[1] + user = find_user_by_external_id(external_id, db=db) + users_list = [user] if user else [] + total = 1 if user else 0 else: response = Users.get_users(skip=skip, limit=limit, db=db) users_list = response["users"] @@ -546,17 +593,33 @@ async def create_user( db: Session = Depends(get_session), ): """Create SCIM User""" - # Check if user already exists - existing_user = Users.get_user_by_email(user_data.userName, db=db) + # Check for duplicate by externalId + if user_data.externalId: + existing_user = find_user_by_external_id(user_data.externalId, db=db) + if existing_user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"User with externalId {user_data.externalId} already exists", + ) + + # Determine primary email (lowercased per RFC 5321) + email = user_data.userName + for entry in user_data.emails: + if entry.primary: + email = entry.value + break + email = email.lower() + + # Check for duplicate by email + existing_user = Users.get_user_by_email(email, db=db) if existing_user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=f"User with email {user_data.userName} already exists", + detail=f"User with email {email} already exists", ) # Create user user_id = str(uuid.uuid4()) - email = user_data.emails[0].value if user_data.emails else user_data.userName # Parse name if provided name = user_data.displayName @@ -571,7 +634,6 @@ async def create_user( if user_data.photos and len(user_data.photos) > 0: profile_image = user_data.photos[0].value - # Create user new_user = Users.insert_new_user( id=user_id, name=name, @@ -587,6 +649,14 @@ async def create_user( detail="Failed to create user", ) + # Store externalId in the scim field + if user_data.externalId: + provider = get_scim_provider() + Users.update_user_scim_by_id( + user_id, provider, user_data.externalId, db=db + ) + new_user = Users.get_user_by_id(user_id, db=db) + return user_to_scim(new_user, request, db=db) @@ -631,7 +701,6 @@ async def update_user( if user_data.photos and len(user_data.photos) > 0: update_data["profile_image_url"] = user_data.photos[0].value - # Update user updated_user = Users.update_user_by_id(user_id, update_data, db=db) if not updated_user: raise HTTPException( @@ -639,6 +708,14 @@ async def update_user( detail="Failed to update user", ) + # Update externalId in the scim field + if user_data.externalId: + provider = get_scim_provider() + Users.update_user_scim_by_id( + user_id, provider, user_data.externalId, db=db + ) + updated_user = Users.get_user_by_id(user_id, db=db) + return user_to_scim(updated_user, request, db=db) @@ -676,6 +753,11 @@ async def patch_user( update_data["email"] = value elif path == "name.formatted": update_data["name"] = value + elif path == "externalId": + provider = get_scim_provider() + Users.update_user_scim_by_id( + user_id, provider, value, db=db + ) # Update user if update_data: From 370a677a38bad1400952551f8b169b499566b47d Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:28:23 +0100 Subject: [PATCH 15/32] fix: pin torch to prevent startup errors on ARM devices (#21385) * fix: rpi * Update requirements-min.txt * Update requirements.txt * Update pyproject.toml --- Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1608a24afe..1c0ae0b3c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -137,7 +137,8 @@ COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt RUN pip3 install --no-cache-dir uv && \ if [ "$USE_CUDA" = "true" ]; then \ # If you use CUDA the whisper and embedding model will be downloaded on first use - pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ + # fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349 + pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ uv pip install --system -r requirements.txt --no-cache-dir && \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \ @@ -145,7 +146,7 @@ RUN pip3 install --no-cache-dir uv && \ python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \ python -c "import nltk; nltk.download('punkt_tab')"; \ else \ - pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ + pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ uv pip install --system -r requirements.txt --no-cache-dir && \ if [ "$USE_SLIM" != "true" ]; then \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ From f027a01ab2ff3b6175af3dd13a4478c265c0544a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 13 Feb 2026 14:39:01 -0600 Subject: [PATCH 16/32] fix: direct model access control --- backend/open_webui/routers/models.py | 27 ++++++++++++++++--- .../workspace/Models/ModelEditor.svelte | 2 +- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index fe4137cab9..aa573dd720 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -8,7 +8,9 @@ import logging from open_webui.models.groups import Groups from open_webui.models.models import ( ModelForm, + ModelMeta, ModelModel, + ModelParams, ModelResponse, ModelListResponse, ModelAccessListResponse, @@ -521,11 +523,30 @@ async def update_model_access_by_id( db: Session = Depends(get_session), ): model = Models.get_model_by_id(form_data.id, db=db) + + # Non-preset models (e.g. direct Ollama/OpenAI models) may not have a DB + # entry yet. Create a minimal one so access grants can be stored. if not model: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, + if user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + model = Models.insert_new_model( + ModelForm( + id=form_data.id, + name=form_data.id, + meta=ModelMeta(), + params=ModelParams(), + ), + user.id, + db=db, ) + if not model: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT("Error creating model entry"), + ) if ( model.user_id != user.id diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 80abd65445..292be8ceeb 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -349,7 +349,7 @@ await updateModelAccessGrants(localStorage.token, model.id, accessGrants); toast.success($i18n.t('Saved')); } catch (error) { - toast.error(`${error}`); + toast.error(error?.detail ?? `${error}`); } } }} From 163211a3676c1612fb88c34af149193eea5de95c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 13 Feb 2026 14:43:06 -0600 Subject: [PATCH 17/32] refac: styling --- .../components/workspace/Models/ModelEditor.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 292be8ceeb..a00e2216b3 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -466,11 +466,11 @@ }} >
-
-
+
+