From 3d99de67716774af2f95f2e3c8e7cc4879464c71 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 23 Feb 2026 15:49:05 -0600 Subject: [PATCH] enh: access grant level perms --- backend/open_webui/config.py | 7 ++++ backend/open_webui/models/access_grants.py | 27 +++++++++++++ backend/open_webui/routers/knowledge.py | 38 ++++++++++++++++++- backend/open_webui/routers/models.py | 14 ++++++- backend/open_webui/routers/notes.py | 26 ++++++++++++- backend/open_webui/routers/prompts.py | 14 ++++++- backend/open_webui/routers/skills.py | 14 ++++++- backend/open_webui/routers/tools.py | 14 ++++++- backend/open_webui/routers/users.py | 8 ++++ .../admin/Users/Groups/Permissions.svelte | 22 +++++++++++ src/lib/components/notes/NoteEditor.svelte | 3 ++ .../Knowledge/CreateKnowledgeBase.svelte | 1 + .../workspace/Knowledge/KnowledgeBase.svelte | 1 + .../workspace/Models/ModelEditor.svelte | 1 + .../workspace/Prompts/PromptEditor.svelte | 1 + .../workspace/Skills/SkillEditor.svelte | 1 + .../workspace/Tools/ToolkitEditor.svelte | 1 + .../workspace/common/AccessControl.svelte | 5 ++- .../common/AccessControlModal.svelte | 2 + .../workspace/common/AddAccessModal.svelte | 3 +- .../workspace/common/MemberSelector.svelte | 3 ++ src/lib/constants/permissions.ts | 3 ++ 22 files changed, 201 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index e2e7d7ea1..b276f9de9 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1433,6 +1433,10 @@ USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = ( == "true" ) +USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = ( + os.environ.get("USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS", "True").lower() == "true" +) + USER_PERMISSIONS_CHAT_CONTROLS = ( os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true" @@ -1590,6 +1594,9 @@ DEFAULT_USER_PERMISSIONS = { "notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING, "public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING, }, + "access_grants": { + "allow_users": USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS, + }, "chat": { "controls": USER_PERMISSIONS_CHAT_CONTROLS, "valves": USER_PERMISSIONS_CHAT_VALVES, diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index 227621bec..93563bec8 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -204,6 +204,33 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool: return False +def has_user_access_grant(access_grants: Optional[list]) -> bool: + """ + Returns True when a direct grant list includes any non-wildcard user grant. + """ + for grant in normalize_access_grants(access_grants): + if grant["principal_type"] == "user" and grant["principal_id"] != "*": + return True + return False + + +def strip_user_access_grants(access_grants: Optional[list]) -> list: + """ + Remove all non-wildcard user grants from the list. + Keeps group grants and the public wildcard (user:*) intact. + """ + if not access_grants: + return [] + return [ + grant + for grant in access_grants + if not ( + (grant.get("principal_type") if isinstance(grant, dict) else getattr(grant, "principal_type", None)) == "user" + and (grant.get("principal_id") if isinstance(grant, dict) else getattr(grant, "principal_id", None)) != "*" + ) + ] + + def grants_to_access_control(grants: list) -> Optional[dict]: """ Convert a list of grant objects (AccessGrantModel or AccessGrantResponse) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 1fedab446..b5cecb6fa 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -30,7 +30,7 @@ from open_webui.storage.provider import Storage from open_webui.constants import ERROR_MESSAGES from open_webui.utils.auth import get_verified_user, get_admin_user from open_webui.utils.access_control import has_permission -from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_user_access_grant, strip_user_access_grants from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL @@ -274,6 +274,18 @@ async def create_new_knowledge( ): form_data.access_grants = [] + # Strip individual user sharing if user lacks permission + if ( + user.role != "admin" + and has_user_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "access_grants.allow_users", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = strip_user_access_grants(form_data.access_grants) + knowledge = Knowledges.insert_new_knowledge(user.id, form_data) if knowledge: @@ -494,6 +506,18 @@ async def update_knowledge_by_id( ): form_data.access_grants = [] + # Strip individual user sharing if user lacks permission + if ( + user.role != "admin" + and has_user_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "access_grants.allow_users", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = strip_user_access_grants(form_data.access_grants) + knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data) if knowledge: # Re-embed knowledge base for semantic search @@ -573,6 +597,18 @@ async def update_knowledge_access_by_id( ) ] + # Strip individual user sharing if user lacks permission + if ( + user.role != "admin" + and has_user_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "access_grants.allow_users", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = strip_user_access_grants(form_data.access_grants) + 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 c41745348..9feb0e854 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -17,7 +17,7 @@ from open_webui.models.models import ( ModelAccessResponse, Models, ) -from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_user_access_grant, strip_user_access_grants from pydantic import BaseModel from open_webui.constants import ERROR_MESSAGES @@ -584,6 +584,18 @@ async def update_model_access_by_id( ) ] + # Strip individual user sharing if user lacks permission + if ( + user.role != "admin" + and has_user_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "access_grants.allow_users", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = strip_user_access_grants(form_data.access_grants) + 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 41bb65f55..7c971fa31 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -28,7 +28,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_permission -from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_user_access_grant, strip_user_access_grants from open_webui.internal.db import get_session from sqlalchemy.orm import Session @@ -296,6 +296,18 @@ async def update_note_by_id( ): form_data.access_grants = [] + # Strip individual user sharing if user lacks permission + if ( + user.role != "admin" + and has_user_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "access_grants.allow_users", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = strip_user_access_grants(form_data.access_grants) + try: note = Notes.update_note_by_id(id, form_data, db=db) await sio.emit( @@ -376,6 +388,18 @@ async def update_note_access_by_id( ) ] + # Strip individual user sharing if user lacks permission + if ( + user.role != "admin" + and has_user_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "access_grants.allow_users", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = strip_user_access_grants(form_data.access_grants) + 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 9653571fb..d79fdbbd6 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, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_user_access_grant, strip_user_access_grants from open_webui.models.groups import Groups from open_webui.models.prompt_history import ( PromptHistories, @@ -492,6 +492,18 @@ async def update_prompt_access_by_id( ) ] + # Strip individual user sharing if user lacks permission + if ( + user.role != "admin" + and has_user_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "access_grants.allow_users", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = strip_user_access_grants(form_data.access_grants) + 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 fb7b01b87..6532b6ee0 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, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_user_access_grant, strip_user_access_grants from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission @@ -360,6 +360,18 @@ async def update_skill_access_by_id( ) ] + # Strip individual user sharing if user lacks permission + if ( + user.role != "admin" + and has_user_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "access_grants.allow_users", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = strip_user_access_grants(form_data.access_grants) + 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 531cfe16a..d60bcfb01 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, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_user_access_grant, strip_user_access_grants from open_webui.utils.plugin import ( load_tool_module_by_id, replace_imports, @@ -595,6 +595,18 @@ async def update_tool_access_by_id( ) ] + # Strip individual user sharing if user lacks permission + if ( + user.role != "admin" + and has_user_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "access_grants.allow_users", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = strip_user_access_grants(form_data.access_grants) + AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db) return Tools.get_tool_by_id(id, db=db) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 1ecf1c019..143a374d9 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -196,6 +196,10 @@ class SharingPermissions(BaseModel): public_notes: bool = True +class AccessGrantsPermissions(BaseModel): + allow_users: bool = True + + class ChatPermissions(BaseModel): controls: bool = True valves: bool = True @@ -239,6 +243,7 @@ class SettingsPermissions(BaseModel): class UserPermissions(BaseModel): workspace: WorkspacePermissions sharing: SharingPermissions + access_grants: AccessGrantsPermissions chat: ChatPermissions features: FeaturesPermissions settings: SettingsPermissions @@ -253,6 +258,9 @@ async def get_default_user_permissions(request: Request, user=Depends(get_admin_ "sharing": SharingPermissions( **request.app.state.config.USER_PERMISSIONS.get("sharing", {}) ), + "access_grants": AccessGrantsPermissions( + **request.app.state.config.USER_PERMISSIONS.get("access_grants", {}) + ), "chat": ChatPermissions( **request.app.state.config.USER_PERMISSIONS.get("chat", {}) ), diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index 820228de0..64273dcbc 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -395,6 +395,28 @@
+
+
{$i18n.t('Access Grants')}
+ +
+
+
+ {$i18n.t('Allow Sharing With Users')} +
+ +
+ {#if defaultPermissions?.access_grants?.allow_users && !permissions.access_grants.allow_users} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +
+
+ +
+
{$i18n.t('Chat Permissions')}
diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte index d16afa01b..c878314a0 100644 --- a/src/lib/components/notes/NoteEditor.svelte +++ b/src/lib/components/notes/NoteEditor.svelte @@ -871,6 +871,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, bind:show={showAccessControlModal} bind:accessGrants={note.access_grants} accessRoles={['read', 'write']} + share={$user?.permissions?.sharing?.notes || $user?.role === 'admin'} + sharePublic={$user?.permissions?.sharing?.public_notes || $user?.role === 'admin'} + shareUsers={$user?.permissions?.access_grants?.allow_users || $user?.role === 'admin'} onChange={async () => { if (id) { try { diff --git a/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte index 9ccae1009..ca29a7948 100644 --- a/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte @@ -115,6 +115,7 @@ accessRoles={['read', 'write']} share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'} + shareUsers={$user?.permissions?.access_grants?.allow_users || $user?.role === "admin"} />
diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte index 5f5032802..909c3eb5d 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte @@ -837,6 +837,7 @@ bind:accessGrants={knowledge.access_grants} share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'} + shareUsers={$user?.permissions?.access_grants?.allow_users || $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 b39637353..b043c6703 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -332,6 +332,7 @@ accessRoles={preset ? ['read', 'write'] : ['read']} share={$user?.permissions?.sharing?.models || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'} + shareUsers={$user?.permissions?.access_grants?.allow_users || $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 e92e5936a..5b63b08f5 100644 --- a/src/lib/components/workspace/Prompts/PromptEditor.svelte +++ b/src/lib/components/workspace/Prompts/PromptEditor.svelte @@ -283,6 +283,7 @@ accessRoles={['read', 'write']} share={$user?.permissions?.sharing?.prompts || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'} + shareUsers={$user?.permissions?.access_grants?.allow_users || $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 569809a5b..6131ea3b4 100644 --- a/src/lib/components/workspace/Skills/SkillEditor.svelte +++ b/src/lib/components/workspace/Skills/SkillEditor.svelte @@ -114,6 +114,7 @@ accessRoles={['read', 'write']} share={$user?.permissions?.sharing?.skills || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_skills || $user?.role === 'admin'} + shareUsers={$user?.permissions?.access_grants?.allow_users || $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 a0f8e5728..89c050367 100644 --- a/src/lib/components/workspace/Tools/ToolkitEditor.svelte +++ b/src/lib/components/workspace/Tools/ToolkitEditor.svelte @@ -193,6 +193,7 @@ class Tools: accessRoles={['read', 'write']} share={$user?.permissions?.sharing?.tools || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin'} + shareUsers={$user?.permissions?.access_grants?.allow_users || $user?.role === 'admin'} onChange={async () => { if (edit && id) { try { diff --git a/src/lib/components/workspace/common/AccessControl.svelte b/src/lib/components/workspace/common/AccessControl.svelte index 6e211e7ba..35965edbf 100644 --- a/src/lib/components/workspace/common/AccessControl.svelte +++ b/src/lib/components/workspace/common/AccessControl.svelte @@ -33,6 +33,7 @@ export let share = true; export let sharePublic = true; + export let shareUsers = true; let groups: any[] = []; const resolvingGroupIds = new Set(); @@ -419,7 +420,7 @@ }); - +
@@ -562,6 +563,7 @@ {/each} + {#if shareUsers} {#each selectedUsers as user}
{/each} + {/if} {#if !hasPublicReadGrant(accessGrants ?? []) && accessGroups.length === 0 && selectedUsers.length === 0}
diff --git a/src/lib/components/workspace/common/AccessControlModal.svelte b/src/lib/components/workspace/common/AccessControlModal.svelte index 145695282..caa8cbfd0 100644 --- a/src/lib/components/workspace/common/AccessControlModal.svelte +++ b/src/lib/components/workspace/common/AccessControlModal.svelte @@ -20,6 +20,7 @@ export let share = true; export let sharePublic = true; + export let shareUsers = true; export let onChange = () => {}; @@ -48,6 +49,7 @@ {accessRoles} {share} {sharePublic} + {shareUsers} />
diff --git a/src/lib/components/workspace/common/AddAccessModal.svelte b/src/lib/components/workspace/common/AddAccessModal.svelte index 5180fef10..ff4b778a9 100644 --- a/src/lib/components/workspace/common/AddAccessModal.svelte +++ b/src/lib/components/workspace/common/AddAccessModal.svelte @@ -7,6 +7,7 @@ import MemberSelector from '$lib/components/workspace/common/MemberSelector.svelte'; export let show = false; + export let shareUsers = true; export let onAdd = (payload: { userIds: string[]; groupIds: string[] }) => {}; let userIds: string[] = []; @@ -51,7 +52,7 @@ }} >
- +
diff --git a/src/lib/components/workspace/common/MemberSelector.svelte b/src/lib/components/workspace/common/MemberSelector.svelte index 5cc46f33e..773188ab7 100644 --- a/src/lib/components/workspace/common/MemberSelector.svelte +++ b/src/lib/components/workspace/common/MemberSelector.svelte @@ -19,6 +19,7 @@ import { getGroups } from '$lib/apis/groups'; export let includeGroups = true; + export let includeUsers = true; export let pagination = false; export let groupIds = []; @@ -237,6 +238,7 @@
{/if} + {#if includeUsers}
{$i18n.t('Users')}
@@ -295,6 +297,7 @@ {/if} {/each}
+ {/if} diff --git a/src/lib/constants/permissions.ts b/src/lib/constants/permissions.ts index 531f3fea8..52f93cecf 100644 --- a/src/lib/constants/permissions.ts +++ b/src/lib/constants/permissions.ts @@ -26,6 +26,9 @@ export const DEFAULT_PERMISSIONS = { notes: false, public_notes: false }, + access_grants: { + allow_users: true + }, chat: { controls: true, valves: true,