This commit is contained in:
Timothy Jaeryang Baek 2026-10-25 14:20:56 +09:00
parent 5cc0ed71d6
commit 5f95008abd
33 changed files with 359 additions and 363 deletions

View file

@ -3,8 +3,8 @@ import time
from typing import Optional
from uuid import uuid4
from open_webui.internal.db import Base, get_async_db_context
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import Base, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse
@ -176,7 +176,6 @@ class CalendarUpdateForm(BaseModel):
color: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
async def validate_calendar_rrule(value: Optional[str]) -> None:
@ -362,8 +361,6 @@ class CalendarTable:
cal.data = {**(cal.data or {}), **update_data['data']}
if 'meta' in update_data:
cal.meta = {**(cal.meta or {}), **update_data['meta']}
if 'access_grants' in update_data:
await AccessGrants.set_access_grants('calendar', id, update_data['access_grants'], db=db)
cal.updated_at = int(time.time_ns())
await db.commit()

View file

@ -16,8 +16,8 @@ from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
CheckConstraint,
Column,
ForeignKey,
String,
Text,
@ -243,12 +243,12 @@ class ChannelForm(BaseModel):
is_private: Optional[bool] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
group_ids: Optional[list[str]] = None
user_ids: Optional[list[str]] = None
class CreateChannelForm(ChannelForm):
access_grants: Optional[list[dict]] = None
type: Optional[str] = None
@ -896,8 +896,6 @@ class ChannelTable:
channel.data = form_data.data
channel.meta = form_data.meta
if form_data.access_grants is not None:
await AccessGrants.set_access_grants('channel', id, form_data.access_grants, db=db)
channel.updated_at = int(time.time_ns())
await db.commit()

View file

@ -792,8 +792,6 @@ class KnowledgeTable:
)
)
await db.commit()
if form_data.access_grants is not None:
await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db)
return await self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)

View file

@ -7,10 +7,10 @@ from copy import deepcopy
from typing import Annotated, Any, Literal
from fastapi import HTTPException
from open_webui.models.model_history import ModelHistory, ModelHistories, model_snapshot
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.access_grants import AccessGrant, AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.model_history import ModelHistories, ModelHistory, model_snapshot
from open_webui.models.users import User, UserModel, UserResponse, Users
from open_webui.utils.misc import json_text_variants
from open_webui.utils.validate import validate_image_url
@ -280,6 +280,7 @@ class ModelsTable:
**data, user_id=user_id, version_id=entry.id, created_at=int(time.time()), updated_at=int(time.time())
)
session.add_all([current, entry])
await AccessGrants.replace_access_grants(session, 'model', form.id, form.access_grants)
else:
if production_version_id is not None:
# Serialize with history deletion before reading the selected snapshot.
@ -322,8 +323,6 @@ class ModelsTable:
)
if result.rowcount != 1:
raise HTTPException(409, {'code': 'version_conflict'})
if form.access_grants is not None or current in session.new:
await AccessGrants.replace_access_grants(session, 'model', form.id, form.access_grants)
return current
async def _written_model(self, session, model):

View file

@ -109,7 +109,6 @@ class NoteUpdateForm(BaseModel):
title: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
@field_validator('data', mode='before')
@classmethod
@ -376,12 +375,9 @@ class NoteTable:
if 'meta' in form_data:
note.meta = {**(note.meta or {}), **(form_data['meta'] or {})}
if not db.is_modified(note) and 'access_grants' not in form_data:
if not db.is_modified(note):
return await self._to_note_model(note, db=db)
if 'access_grants' in form_data:
await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db)
note.updated_at = int(time.time_ns())
await db.commit()

View file

@ -442,17 +442,6 @@ class PromptsTable:
prompt.data = form_data.data or prompt.data
prompt.meta = form_data.meta or prompt.meta
prompt.updated_at = int(time.time())
if form_data.access_grants is not None:
previous_access_grants = {
(grant.principal_type, grant.principal_id, grant.permission) for grant in current_access_grants
}
await AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=session)
current_access_grants = await self._get_access_grants(prompt.id, db=session)
if previous_access_grants != {
(grant.principal_type, grant.principal_id, grant.permission) for grant in current_access_grants
}:
content_changed = True
await session.commit()
# Create history entry only if content changed
@ -522,17 +511,6 @@ class PromptsTable:
if form_data.tags is not None:
prompt.tags = form_data.tags
if form_data.access_grants is not None:
previous_access_grants = {
(grant.principal_type, grant.principal_id, grant.permission) for grant in current_access_grants
}
await AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=session)
current_access_grants = await self._get_access_grants(prompt.id, db=session)
if previous_access_grants != {
(grant.principal_type, grant.principal_id, grant.permission) for grant in current_access_grants
}:
content_changed = True
prompt.updated_at = int(time.time())
await session.commit()

View file

@ -433,8 +433,6 @@ class SkillsTable:
)
if result.rowcount != 1:
raise HTTPException(409, {'code': 'version_conflict'})
if updated.get('access_grants') is not None:
await AccessGrants.replace_access_grants(session, 'skill', id, updated['access_grants'])
await session.commit()
await session.refresh(skill)
grants = (

View file

@ -5,6 +5,8 @@ from __future__ import annotations
import logging
import time
from fastapi import HTTPException
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.access_grants import AccessGrant, AccessGrantModel, AccessGrants
@ -12,7 +14,6 @@ from open_webui.models.groups import Groups
from open_webui.models.tool_history import ToolHistories, ToolHistory, tool_snapshot
from open_webui.models.users import UserResponse, Users
from open_webui.utils.valves import decrypt_valves, encrypt_valves, validate_valves
from fastapi import HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, String, Text, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -375,10 +376,8 @@ class ToolsTable:
if not allow_code_changes and updated.get('content', tool.content) != tool.content:
raise HTTPException(401, 'You do not have permission to change executable Tool code')
updated = dict(updated)
grants = updated.pop('access_grants', None)
updated.pop('access_grants', None)
await self._write_tool(session, tool, updated, user_id, version_id, module)
if grants is not None:
await AccessGrants.replace_access_grants(session, 'tool', id, grants)
await session.flush()
grants = (
(await session.execute(select(AccessGrant).filter_by(resource_type='tool', resource_id=id)))

View file

@ -23,10 +23,11 @@ from open_webui.models.calendar import (
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants
from open_webui.utils.auth import get_verified_user
from open_webui.utils.calendar import expand_recurring_event
from open_webui.utils.recurrence import schedule_start_ns
from pydantic import BaseModel
log = logging.getLogger(__name__)
@ -123,7 +124,7 @@ async def create_calendar(request: Request, form_data: CalendarForm, user: UserM
user.id,
user.role,
form_data.access_grants,
'sharing.public_calendars',
resource_type='calendar',
)
calendar = await Calendars.insert_new_calendar(user.id, form_data)
await publish_event(
@ -415,25 +416,7 @@ async def update_calendar(
request: Request, calendar_id: str, form_data: CalendarUpdateForm, user: UserModel = Depends(get_verified_user)
):
await check_calendar_permission(request, user)
cal = await _check_calendar_access(calendar_id, user, 'write')
# Only owner/admin can change access grants
if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=403, detail='Only owner can manage sharing')
# Strip public/user grants the requesting user is not permitted to assign
# (matches the channel/notes/models pattern). The owner-only check above
# only restricts WHO can set grants; this filter restricts WHICH grants
# they may set, so a non-admin owner cannot make their calendar
# publicly readable/writable without the corresponding sharing permission.
if form_data.access_grants is not None:
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_calendars',
)
await _check_calendar_access(calendar_id, user, 'write')
updated = await Calendars.update_calendar_by_id(calendar_id, form_data)
if not updated:
@ -448,6 +431,30 @@ async def update_calendar(
return updated
class CalendarAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post('/{calendar_id}/access/update', response_model=CalendarModel)
async def update_calendar_access_by_id(
request: Request,
calendar_id: str,
form_data: CalendarAccessGrantsForm,
user: UserModel = Depends(get_verified_user),
):
await check_calendar_permission(request, user)
cal = await _check_calendar_access(calendar_id, user, 'write')
if cal.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=403, detail='Only owner can manage sharing')
grants = await validate_access_grants(
await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, resource_type='calendar'
)
cal.access_grants = await AccessGrants.set_access_grants('calendar', calendar_id, grants)
await publish_event(request, EVENTS.CALENDAR_UPDATED, actor=user, subject_id=cal.id, data={'name': cal.name})
return cal
@router.delete('/{calendar_id}/delete')
async def delete_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)):
await check_calendar_permission(request, user)

View file

@ -42,15 +42,15 @@ from open_webui.models.users import (
Users,
)
from open_webui.socket.main import (
encryption,
emit_to_users,
encryption,
enter_room_for_users,
get_user_ids_from_room,
leave_room_for_users,
sio,
)
from open_webui.tasks import create_task
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants
from open_webui.utils.apps import (
check_app_channel_access,
create_app_message,
@ -334,7 +334,7 @@ async def create_new_channel(
user.id,
user.role,
form_data.access_grants,
'sharing.public_channels',
resource_type='channel',
)
try:
@ -795,27 +795,9 @@ async def update_channel_by_id(
if channel.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_channels',
)
previous_access_grants = channel.access_grants
try:
await encryption.invalidate(id)
channel = await Channels.update_channel_by_id(id, form_data, db=db)
# Group and DM channels use membership instead of access grants.
if form_data.access_grants is not None and channel.type not in ['group', 'dm']:
revoked_user_ids = await AccessGrants.get_revoked_user_ids_by_resource(
'channel', id, previous_access_grants, db=db
)
revoked_user_ids.discard(channel.user_id)
await leave_room_for_users(f'channel:{id}', list(revoked_user_ids))
await publish_event(
request,
EVENTS.CHANNEL_UPDATED,
@ -829,6 +811,48 @@ async def update_channel_by_id(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
class ChannelAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post('/{id}/access/update', response_model=ChannelModel)
async def update_channel_access_by_id(
request: Request,
id: str,
form_data: ChannelAccessGrantsForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_channels_access(request, user)
channel = await Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND)
if channel.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
# Group and DM channels manage access through membership instead.
if channel.type in ['group', 'dm']:
raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
resource_type='channel',
db=db,
)
previous_access_grants = channel.access_grants
await encryption.invalidate(id)
channel.access_grants = await AccessGrants.set_access_grants('channel', id, grants, db=db)
revoked_user_ids = await AccessGrants.get_revoked_user_ids_by_resource('channel', id, previous_access_grants, db=db)
revoked_user_ids.discard(channel.user_id)
await leave_room_for_users(f'channel:{id}', list(revoked_user_ids))
await publish_event(
request, EVENTS.CHANNEL_UPDATED, actor=user, subject_id=id, data={'name': channel.name, 'type': channel.type}
)
return channel
############################
# DeleteChannelById
############################

View file

@ -40,7 +40,7 @@ from open_webui.models.tags import TagModel, Tags
from open_webui.models.users import UserModel
from open_webui.socket.main import get_event_emitter
from open_webui.tasks import get_response_streams_by_chat_id, has_active_tasks, stop_item_tasks
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control import has_permission, validate_access_grants
from open_webui.utils.access_control.folders import has_chat_move_access, has_folder_write_access
from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user
from open_webui.utils.chat_fork import build_fork_history
@ -2229,13 +2229,12 @@ async def update_shared_chat_access_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_chats',
'sharing.open_chats',
resource_type='shared_chat',
db=db,
)

View file

@ -12,10 +12,11 @@ from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.shared_chats import ChatShareMode
from open_webui.models.access_grants import AccessGrants
from open_webui.models.automations import Automations
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.config import Config
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.models.folders import (
FolderForm,
FolderModel,
@ -23,17 +24,16 @@ from open_webui.models.folders import (
Folders,
FolderUpdateForm,
)
from open_webui.models.access_grants import AccessGrants
from open_webui.models.automations import Automations
from open_webui.models.groups import Groups
from open_webui.models.shared_chats import ChatShareMode
from open_webui.models.users import Users
from open_webui.utils.access_control import has_permission
from open_webui.tasks import has_active_tasks
from open_webui.utils.access_control import (
filter_allowed_access_grants,
has_permission,
validate_access_grants,
)
from open_webui.utils.access_control.files import can_read_all_folder_files, get_accessible_folder_files
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.tasks import has_active_tasks
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@ -555,12 +555,12 @@ async def update_folder_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
None,
resource_type='folder',
db=db,
)

View file

@ -43,7 +43,7 @@ from open_webui.routers.retrieval import (
process_files_batch,
)
from open_webui.storage.provider import Storage
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.json_codec import JSONCodec
@ -338,7 +338,7 @@ async def create_new_knowledge(
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
resource_type='knowledge',
)
knowledge = await Knowledges.insert_new_knowledge(user.id, form_data)
@ -910,7 +910,7 @@ async def create_external_knowledge(
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
resource_type='knowledge',
)
knowledge = await Knowledges.insert_new_knowledge(
@ -969,7 +969,7 @@ async def create_external_knowledge_source(
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
resource_type='knowledge',
)
connections = await _get_external_connections()
@ -1040,14 +1040,6 @@ async def update_external_knowledge_source(
if not test_result.get('documents'):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Test query returned no results.')
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
)
connections[idx] = connection
await _set_external_connections(connections)
@ -1176,14 +1168,6 @@ async def update_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
)
knowledge = await Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
if knowledge:
# Re-embed knowledge base for semantic search
@ -1252,12 +1236,12 @@ async def update_knowledge_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
resource_type='knowledge',
)
knowledge.access_grants = await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db)

View file

@ -28,7 +28,7 @@ from open_webui.env import (
)
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants, normalize_access_grants
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.files import Files
from open_webui.models.groups import Groups
@ -45,17 +45,22 @@ from open_webui.models.models import (
Models,
)
from open_webui.storage.provider import Storage
from open_webui.utils.access_control import filter_allowed_access_grants, has_access, has_permission
from open_webui.utils.access_control import (
filter_allowed_access_grants,
has_access,
has_permission,
validate_access_grants,
)
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.chat_variables import get_chat_variables_schema
from open_webui.utils.models import get_all_models
from open_webui.utils.validate import BACKGROUND_IMAGE_MAX_BYTES, validate_background_image
from open_webui.utils.voice_avatar import (
AVATAR_MAX_BYTES,
ANIMATION_MAX_BYTES,
validate_voice_avatar,
AVATAR_MAX_BYTES,
validate_voice_animation,
validate_voice_avatar,
)
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@ -410,7 +415,7 @@ async def create_new_model(
user.id,
user.role,
form_data.access_grants,
'sharing.public_models',
resource_type='model',
)
await _check_model_controls(form_data, None, user, request)
@ -605,17 +610,6 @@ async def import_models(
model_data['params'] = model_data.get('params', {})
updated_model = ModelForm(**{**existing_model.model_dump(), **model_data})
# Only filter access_grants when explicitly provided
# in the payload to avoid altering existing ACLs on
# metadata-only imports.
if 'access_grants' in model_data:
updated_model.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
updated_model.access_grants,
'sharing.public_models',
)
imported_model = updated_model
else:
# Insert new model
@ -656,7 +650,7 @@ async def import_models(
user.id,
user.role,
new_model.access_grants,
'sharing.public_models',
resource_type='model',
)
imported_model = new_model
@ -1205,30 +1199,6 @@ async def update_model_by_id(
form_data.meta.background_image_url = model.meta.background_image_url
await _verify_background_image(form_data.meta.background_image_url, user, db, model.meta.background_image_url)
if form_data.access_grants is not None:
# The editor resends every stored grant, so re-checking them would strip sharing this user cannot re-create.
existing_access_grants = {
(grant.principal_type, grant.principal_id, grant.permission) for grant in model.access_grants
}
submitted_access_grants_map = {
(grant['principal_type'], grant['principal_id'], grant['permission']): grant
for grant in normalize_access_grants(form_data.access_grants)
}
preserved_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants
]
new_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants
]
form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
new_access_grants,
'sharing.public_models',
)
await _check_model_controls(form_data, model, user, request)
model = await Models.update_model_by_id(form_data.id, form_data, db=db, user_id=user.id)
if model:
@ -1302,12 +1272,12 @@ async def update_model_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_models',
resource_type='model',
)
await AccessGrants.set_access_grants('model', form_data.id, form_data.access_grants, db=db)

View file

@ -11,7 +11,7 @@ from open_webui.config import (
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.access_grants import AccessGrantModel, AccessGrants, has_public_write_access_grant
from open_webui.models.chats import ChatForm, ChatResponse, Chats
from open_webui.models.config import Config
from open_webui.models.groups import Groups
@ -20,6 +20,7 @@ from open_webui.models.notes import (
NoteListResponse,
NoteModel,
Notes,
NoteUpdateForm,
NoteUserResponse,
)
from open_webui.models.users import UserModel, UserResponse, Users
@ -27,8 +28,7 @@ from open_webui.socket.main import leave_room_for_users, sio
from open_webui.utils.access_control import (
filter_allowed_access_grants,
has_permission,
has_public_read_access_grant,
has_public_write_access_grant,
validate_access_grants,
)
from open_webui.utils.access_control.files import has_access_to_note_files
from open_webui.utils.auth import get_admin_user, get_verified_user
@ -233,7 +233,7 @@ async def create_new_note(
user.id,
user.role,
form_data.access_grants,
'sharing.public_notes',
resource_type='note',
db=db,
)
@ -512,7 +512,7 @@ async def create_note_chat_by_id(
async def update_note_by_id(
request: Request,
id: str,
form_data: NoteForm,
form_data: NoteUpdateForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@ -541,22 +541,8 @@ async def update_note_by_id(
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
if form_data.access_grants is not None:
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_notes',
db=db,
)
previous_access_grants = note.access_grants
try:
note = await Notes.update_note_by_id(id, form_data, db=db)
if form_data.access_grants is not None:
await leave_note_rooms_for_revoked_users(note, previous_access_grants, db=db)
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
note.is_pinned = note.id in pinned_note_ids
@ -621,12 +607,12 @@ async def update_note_access_by_id(
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_notes',
resource_type='note',
)
await AccessGrants.set_access_grants('note', id, form_data.access_grants, db=db)

View file

@ -7,7 +7,7 @@ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants, normalize_access_grants
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import (
@ -23,7 +23,7 @@ from open_webui.models.prompts import (
Prompts,
PromptUserResponse,
)
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants
from open_webui.utils.auth import get_admin_user, get_verified_user
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@ -171,7 +171,7 @@ async def create_new_prompt(
user.id,
user.role,
form_data.access_grants,
'sharing.public_prompts',
resource_type='prompt',
)
prompt = await Prompts.get_prompt_by_command(form_data.command, db=db)
@ -289,29 +289,6 @@ async def update_prompt_by_id(
detail=ERROR_MESSAGES.COMMAND_TAKEN,
)
if form_data.access_grants is not None:
existing_access_grants = {
(grant.principal_type, grant.principal_id, grant.permission) for grant in prompt.access_grants
}
submitted_access_grants_map = {
(grant['principal_type'], grant['principal_id'], grant['permission']): grant
for grant in normalize_access_grants(form_data.access_grants)
}
preserved_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants
]
new_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants
]
form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
new_access_grants,
'sharing.public_prompts',
)
# Use the ID from the found prompt
updated_prompt = await Prompts.update_prompt_by_id(prompt.id, form_data, user.id, db=db)
if updated_prompt:
@ -484,12 +461,12 @@ async def update_prompt_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_prompts',
resource_type='prompt',
)
await AccessGrants.set_access_grants('prompt', prompt_id, form_data.access_grants, db=db)

View file

@ -28,7 +28,7 @@ from open_webui.models.skills import (
SkillUserResponse,
get_skill_snapshot,
)
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.skill_files import (
MAX_IMPORT_BYTES,
@ -261,7 +261,7 @@ async def create_new_skill(
user.id,
user.role,
form_data.access_grants,
'sharing.public_skills',
resource_type='skill',
)
try:
@ -373,22 +373,9 @@ async def update_skill_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Strip public/user grants the requesting user is not permitted to assign
# (matches the channel/notes/calendar pattern). The access check above only
# restricts WHO can write to the skill; this filter restricts WHICH grants
# they may set, so a non-admin owner cannot make their own skill publicly
# readable/writable without sharing.public_skills permission.
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_skills',
)
try:
updated = {
**form_data.model_dump(exclude={'id'}, exclude_unset=True),
**form_data.model_dump(exclude={'id', 'access_grants'}, exclude_unset=True),
}
skill = await Skills.update_skill_by_id(id, updated, db=db, user_id=user.id)
@ -456,12 +443,12 @@ async def update_skill_access_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_skills',
resource_type='skill',
)
await AccessGrants.set_access_grants('skill', id, form_data.access_grants, db=db)

View file

@ -19,7 +19,7 @@ from open_webui.env import (
)
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants, normalize_access_grants
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
@ -36,6 +36,7 @@ from open_webui.utils.access_control import (
filter_allowed_access_grants,
has_connection_access,
has_permission,
validate_access_grants,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.plugin import (
@ -420,7 +421,7 @@ async def create_new_tools(
user.id,
user.role,
form_data.access_grants,
'sharing.public_tools',
resource_type='tool',
)
form_data.content = replace_imports(form_data.content)
@ -571,37 +572,11 @@ async def _update_tool(request, id, form_data, user, db, version_id=None):
specs = get_local_tool_specs(tool_module)
if version_id is None and form_data.access_grants is not None:
existing_access_grants = {
(grant.principal_type, grant.principal_id, grant.permission) for grant in tools.access_grants
}
submitted_access_grants_map = {
(grant['principal_type'], grant['principal_id'], grant['permission']): grant
for grant in normalize_access_grants(form_data.access_grants)
}
preserved_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants
]
new_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants
]
form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
new_access_grants,
'sharing.public_tools',
)
updated = {
**form_data.model_dump(exclude={'id'}),
**form_data.model_dump(exclude={'id', 'access_grants'}),
'specs': specs,
}
if version_id is not None:
updated.pop('access_grants', None)
tools = await Tools.update_tool_by_id(
id,
updated,
@ -677,12 +652,12 @@ async def update_tool_access_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_tools',
resource_type='tool',
)
await AccessGrants.set_access_grants('tool', id, form_data.access_grants, db=db)

View file

@ -2,14 +2,11 @@ import logging
from copy import deepcopy
from typing import Any
from fastapi import HTTPException
from open_webui.config import DEFAULT_USER_PERMISSIONS
from open_webui.models.access_grants import (
has_anyone_read_access_grant,
has_public_read_access_grant,
has_public_write_access_grant,
has_user_access_grant,
normalize_access_grants,
strip_anyone_access_grants,
strip_user_access_grants,
)
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel
@ -218,85 +215,91 @@ def migrate_access_control(data: dict, ac_key: str = 'access_control', grants_ke
data.pop(ac_key, None)
# General sharing, all authenticated users, and anonymous readers, respectively.
_SHARING_PERMISSION_KEYS = {
'model': ('sharing.models', 'sharing.public_models', None),
'knowledge': ('sharing.knowledge', 'sharing.public_knowledge', None),
'prompt': ('sharing.prompts', 'sharing.public_prompts', None),
'tool': ('sharing.tools', 'sharing.public_tools', None),
'skill': ('sharing.skills', 'sharing.public_skills', None),
'note': ('sharing.notes', 'sharing.public_notes', None),
'folder': ('sharing.folders', None, None),
'channel': (None, 'sharing.public_channels', None),
'calendar': (None, 'sharing.public_calendars', None),
'shared_chat': (None, 'sharing.public_chats', 'sharing.open_chats'),
}
async def filter_allowed_access_grants(
default_permissions: dict[str, Any],
user_id: str,
user_role: str,
access_grants: list,
public_permission_key: str,
anyone_permission_key: str | None = None,
access_grants: list | None,
resource_type: str,
*,
db: AsyncSession | None = None,
) -> list:
"""
Checks if the user has the required permissions to grant access to a resource.
Returns the filtered list of access grants if permissions are missing.
"""
) -> list | None:
"""Filter initial sharing by the resource policy when creating an item."""
if resource_type not in _SHARING_PERMISSION_KEYS:
raise ValueError(f'Unsupported sharing resource type: {resource_type}')
sharing_permission_key, public_permission_key, anyone_permission_key = _SHARING_PERMISSION_KEYS[resource_type]
if not access_grants:
return access_grants
if has_anyone_read_access_grant(access_grants) and (
not anyone_permission_key
or (
user_role != 'admin'
and not await has_permission(
user_id,
anyone_permission_key,
default_permissions,
db=db,
)
)
):
access_grants = normalize_access_grants(access_grants)
# Unsupported audiences are forbidden even for admins.
if anyone_permission_key is None:
access_grants = strip_anyone_access_grants(access_grants)
if public_permission_key is None:
access_grants = [
grant for grant in access_grants if (grant['principal_type'], grant['principal_id']) != ('user', '*')
]
if user_role == 'admin':
if user_role == 'admin' or not access_grants:
return access_grants
# Check if user can share publicly
if (
has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants)
) and not await has_permission(
user_id,
public_permission_key,
default_permissions,
db=db,
):
access_grants = [
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))
== '*'
)
]
permissions = await get_permissions(user_id, default_permissions, db=db)
# Strip individual user sharing if user lacks permission
if has_user_access_grant(access_grants) and not await has_permission(
user_id,
'access_grants.allow_users',
default_permissions,
db=db,
):
access_grants = strip_user_access_grants(access_grants)
def allowed(key: str | None) -> bool:
if key is None:
return False
section, name = key.split('.')
return bool(permissions.get(section, {}).get(name, False))
if any(
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) == 'group'
if sharing_permission_key and not allowed(sharing_permission_key):
return []
allowed_audiences = {
'anyone': allowed(anyone_permission_key),
'public': allowed(public_permission_key),
'user': allowed('access_grants.allow_users'),
'group': allowed('access_grants.allow_groups'),
}
return [
grant
for grant in access_grants
) and not await has_permission(
user_id,
'access_grants.allow_groups',
default_permissions,
db=db,
):
access_grants = [
grant
for grant in access_grants
if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
!= 'group'
if allowed_audiences[
'public' if (grant['principal_type'], grant['principal_id']) == ('user', '*') else grant['principal_type']
]
]
return access_grants
async def validate_access_grants(
default_permissions: dict[str, Any],
user_id: str,
user_role: str,
access_grants: list,
resource_type: str,
*,
db: AsyncSession | None = None,
) -> list:
"""Reject unauthorized sharing changes before replacing any stored grants."""
grants = normalize_access_grants(access_grants)
allowed = await filter_allowed_access_grants(default_permissions, user_id, user_role, grants, resource_type, db=db)
if len(allowed) != len(grants):
raise HTTPException(403, 'You do not have permission to share with the requested audience')
return allowed
async def has_base_model_access(

View file

@ -131,7 +131,7 @@ export const createCalendar = async (token: string, form: CalendarForm): Promise
export const updateCalendar = async (
token: string,
calendarId: string,
form: Partial<CalendarForm>
form: Partial<Omit<CalendarForm, 'access_grants'>>
): Promise<CalendarModel> => {
let error = null;
@ -142,7 +142,7 @@ export const updateCalendar = async (
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
body: JSON.stringify({ ...form, access_grants: undefined })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@ -456,3 +456,36 @@ export const searchCalendarEvents = async (
return res;
};
export const updateCalendarAccessGrants = async (
token: string,
id: string,
accessGrants: any[]
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${id}/access/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ access_grants: accessGrants })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -333,7 +333,7 @@ export const removeMembersById = async (
export const updateChannelById = async (
token: string = '',
channel_id: string,
channel: ChannelForm
channel: Omit<ChannelForm, 'access_grants'>
) => {
let error = null;
@ -344,7 +344,7 @@ export const updateChannelById = async (
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...channel })
body: JSON.stringify({ ...channel, access_grants: undefined })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@ -1043,3 +1043,32 @@ export const removeAppFromChannel = async (
return res;
};
export const updateChannelAccessGrants = async (token: string, id: string, accessGrants: any[]) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${id}/access/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ access_grants: accessGrants })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -288,7 +288,7 @@ export const updateExternalKnowledgeSource = async (token: string, id: string, p
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(payload)
body: JSON.stringify({ ...payload, access_grants: undefined })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@ -608,7 +608,6 @@ type KnowledgeUpdateForm = {
name?: string;
description?: string;
data?: object;
access_grants?: object[];
};
export const updateKnowledgeById = async (token: string, id: string, form: KnowledgeUpdateForm) => {
@ -624,8 +623,7 @@ export const updateKnowledgeById = async (token: string, id: string, form: Knowl
body: JSON.stringify({
name: form?.name ? form.name : undefined,
description: form?.description ? form.description : undefined,
data: form?.data ? form.data : undefined,
access_grants: form.access_grants
data: form?.data ? form.data : undefined
})
})
.then(async (res) => {

View file

@ -326,15 +326,13 @@ export const toggleModelById = async (token: string, id: string) => {
export const updateModelById = async (token: string, id: string, model: object) => {
let error = null;
const { base_model_id, name, meta, params, access_grants, is_active, commit_message } =
model as any;
const { base_model_id, name, meta, params, is_active, commit_message } = model as any;
const payload = {
id,
base_model_id,
name,
meta,
params,
access_grants,
is_active,
commit_message
};

View file

@ -315,7 +315,11 @@ export const createNoteChatById = async (token: string, id: string) => {
return res;
};
export const updateNoteById = async (token: string, id: string, note: NoteItem) => {
export const updateNoteById = async (
token: string,
id: string,
note: Omit<NoteItem, 'access_grants'>
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/update`, {
@ -326,7 +330,8 @@ export const updateNoteById = async (token: string, id: string, note: NoteItem)
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...note
...note,
access_grants: undefined
})
})
.then(async (res) => {

View file

@ -262,7 +262,10 @@ export const getPromptById = async (token: string, promptId: string) => {
return res;
};
export const updatePromptById = async (token: string, prompt: PromptItem) => {
export const updatePromptById = async (
token: string,
prompt: Omit<PromptItem, 'access_grants'>
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${prompt.id}/update`, {
@ -272,7 +275,7 @@ export const updatePromptById = async (token: string, prompt: PromptItem) => {
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(prompt)
body: JSON.stringify({ ...prompt, access_grants: undefined })
})
.then(async (res) => {
if (!res.ok) throw await res.json();

View file

@ -217,7 +217,8 @@ export const updateSkillById = async (token: string, id: string, skill: object)
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...skill
...skill,
access_grants: undefined
})
})
.then(async (res) => {

View file

@ -214,7 +214,8 @@ export const updateToolById = async (token: string, id: string, tool: object) =>
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...tool
...tool,
access_grants: undefined
})
})
.then(async (res) => {

View file

@ -20,7 +20,8 @@
searchKnowledgeBases,
testExternalKnowledgeSource,
updateExternalKnowledgeConnection,
updateExternalKnowledgeSource
updateExternalKnowledgeSource,
updateKnowledgeAccessGrants
} from '$lib/apis/knowledge';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
@ -173,7 +174,7 @@
tableName: sourceConfig.table_name ?? defaults.tableName,
collectionField: sourceConfig.collection_field ?? defaults.collectionField
};
accessGrants = item.access_grants ?? [];
accessGrants = structuredClone(item.access_grants ?? []);
showSourceModal = true;
};
@ -352,7 +353,6 @@
description: sourceForm.description,
connection: connectionPayload(),
source: sourcePayload(),
access_grants: accessGrants,
test_query: sourceForm.testQuery,
test_count: 5
}).catch((error) => {
@ -773,6 +773,22 @@
share={true}
sharePublic={true}
shareUsers={true}
onChange={async () => {
if (editingItem) {
try {
await updateKnowledgeAccessGrants(
localStorage.token,
editingItem.id,
accessGrants
);
editingItem.access_grants = structuredClone(accessGrants);
toast.success($i18n.t('Saved'));
} catch (error) {
accessGrants = structuredClone(editingItem.access_grants ?? []);
toast.error(`${error}`);
}
}
}}
/>
<div class="flex justify-between items-center pt-3 text-sm font-normal">

View file

@ -58,11 +58,10 @@
edit={true}
{onUpdate}
onSubmit={async (payload: any) => {
const { name, is_private, access_grants, group_ids, user_ids } = payload ?? {};
const { name, is_private, group_ids, user_ids } = payload ?? {};
const res = await updateChannelById(localStorage.token, channel.id, {
name,
is_private,
access_grants,
group_ids,
user_ids
}).catch((error) => {

View file

@ -1,12 +1,16 @@
<script lang="ts">
import { getContext, createEventDispatcher, onMount } from 'svelte';
const i18n = getContext('i18n');
const i18n = getContext<any>('i18n');
import { toast } from 'svelte-sonner';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { createNewChannel, deleteChannelById } from '$lib/apis/channels';
import {
createNewChannel,
deleteChannelById,
updateChannelAccessGrants
} from '$lib/apis/channels';
import { user } from '$lib/stores';
import Spinner from '$lib/components/common/Spinner.svelte';
@ -31,7 +35,7 @@
let name = '';
let isPrivate = null;
let accessGrants = [];
let accessGrants: any[] = [];
let groupIds = [];
let userIds = [];
@ -66,7 +70,7 @@
type: type,
name: name.replace(/\s/g, '-'),
is_private: type === 'group' ? (isPrivate ?? true) : null,
access_grants: type === '' ? accessGrants : [],
...(!edit ? { access_grants: type === '' ? accessGrants : [] } : {}),
group_ids: groupIds,
user_ids: userIds
});
@ -90,7 +94,7 @@
} else {
isPrivate = null;
}
accessGrants = channel?.access_grants ?? [];
accessGrants = structuredClone(channel?.access_grants ?? []);
userIds = channel?.user_ids ?? [];
}
};
@ -237,7 +241,27 @@
{#if type !== 'dm'}
<div class="-mx-2 mb-1 mt-2.5 px-2">
{#if type === ''}
<AccessControl bind:accessGrants accessRoles={['read', 'write']} />
<AccessControl
bind:accessGrants
accessRoles={['read', 'write']}
onChange={async () => {
if (edit && channel?.id) {
try {
await updateChannelAccessGrants(
localStorage.token,
channel.id,
accessGrants
);
channel.access_grants = structuredClone(accessGrants);
toast.success($i18n.t('Saved'));
onUpdate();
} catch (error) {
accessGrants = structuredClone(channel.access_grants ?? []);
toast.error(`${error}`);
}
}
}}
/>
{:else if type === 'group'}
<Visibility
state={isPrivate ? 'private' : 'public'}

View file

@ -19,6 +19,7 @@
import { getLanguages } from '$lib/i18n';
import {
getBaseModelTags,
updateModelAccessGrants,
getModelTags,
type ModelHistoryEntry,
type ModelSnapshot
@ -230,7 +231,7 @@
let builtinTools = {};
let actionIds = [];
let accessGrants = [];
let accessGrants: any[] = [];
let terminalId = '';
$: hasTerminal =
$config?.features?.enable_terminal_servers || $config?.features?.enable_direct_integrations;
@ -397,7 +398,8 @@
modelInfo.params = { ...modelInfo.params, ...params };
modelInfo.access_grants = accessGrants;
if (edit) delete modelInfo.access_grants;
else modelInfo.access_grants = accessGrants;
modelInfo.meta.capabilities = capabilities;
if (enableDescription) {
@ -838,6 +840,18 @@
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) || $user?.role === 'admin'}
allowGroups={($user?.permissions?.access_grants?.allow_groups ?? true) ||
$user?.role === 'admin'}
onChange={async () => {
if (edit && model?.id) {
try {
await updateModelAccessGrants(localStorage.token, model.id, model.name, accessGrants);
model.access_grants = structuredClone(accessGrants);
toast.success($i18n.t('Saved'));
} catch (error) {
accessGrants = structuredClone(model.access_grants ?? []);
toast.error(`${error}`);
}
}
}}
/>
<div class="flex h-full min-h-0 w-full flex-col">

View file

@ -239,7 +239,7 @@
command,
content,
tags: tags.map((tag) => tag.name),
access_grants: accessGrants,
...(!edit ? { access_grants: accessGrants } : {}),
commit_message: commitMessage || undefined,
is_production: production
});

View file

@ -252,7 +252,7 @@ class Tools:
name,
meta: { ...meta, i18n: pruneEmptyLocaleEntries(meta.i18n) },
content,
access_grants: accessGrants
...(!edit ? { access_grants: accessGrants } : {})
});
if (saved) {
if (saved.version_id && saved.content !== undefined) await applyProduction(saved);