This commit is contained in:
Timothy Jaeryang Baek 2026-10-05 14:01:02 +04:00
parent 8f4f29d834
commit d4c561d9f2
49 changed files with 1243 additions and 466 deletions

View file

@ -855,7 +855,7 @@ async def event_target_matches(
if user_group_ids is None:
from open_webui.models.groups import Groups
groups_by_user = await Groups.get_groups_by_member_ids(list(user_ids))
groups_by_user = await Groups.get_groups_by_member_ids(list(user_ids), include_inherited=True)
user_group_ids = {user_id: {group.id for group in groups} for user_id, groups in groups_by_user.items()}
return any(group_ids.intersection(target_group_ids) for group_ids in user_group_ids.values())

View file

@ -0,0 +1,33 @@
"""Add single-parent group hierarchy.
Revision ID: b8e4f0a3c752
Revises: a7d3e9f2b641
"""
from alembic import op
import sqlalchemy as sa
revision = 'b8e4f0a3c752'
down_revision = 'a7d3e9f2b641'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('group') as batch:
batch.add_column(
sa.Column(
'parent_group_id',
sa.Text(),
sa.ForeignKey('group.id', name='fk_group_parent', ondelete='SET NULL'),
nullable=True,
)
)
batch.create_index('ix_group_parent_group_id', ['parent_group_id'])
def downgrade():
with op.batch_alter_table('group') as batch:
batch.drop_index('ix_group_parent_group_id')
batch.drop_constraint('fk_group_parent', type_='foreignkey')
batch.drop_column('parent_group_id')

View file

@ -595,7 +595,7 @@ class AccessGrantsTable:
if user_group_ids is None:
from open_webui.models.groups import Groups
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = {group.id for group in user_groups}
if user_group_ids:
@ -651,7 +651,7 @@ class AccessGrantsTable:
if user_group_ids is None:
from open_webui.models.groups import Groups
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = {group.id for group in user_groups}
if user_group_ids:
@ -730,7 +730,7 @@ class AccessGrantsTable:
group_ids.append(grant.principal_id)
if group_ids:
group_user_ids = await Groups.get_group_user_ids_by_ids(group_ids, db=db)
group_user_ids = await Groups.get_group_user_ids_by_ids(group_ids, db=db, include_inherited=True)
for members in group_user_ids.values():
user_ids.update(members)
return user_ids

View file

@ -291,7 +291,7 @@ class CalendarTable:
async def get_calendars_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
"""Owned + shared calendars."""
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = [g.id for g in user_groups]
stmt = select(Calendar)
@ -497,7 +497,7 @@ class CalendarEventTable:
Recurring events are fetched if they have any rrule (expansion in Python).
"""
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = [g.id for g in user_groups]
# Get calendar IDs accessible to user
@ -599,7 +599,7 @@ class CalendarEventTable:
db: Optional[AsyncSession] = None,
) -> CalendarEventListResponse:
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = [g.id for g in user_groups]
# Get accessible calendar IDs

View file

@ -289,7 +289,7 @@ class ChannelTable:
users.add(invited_by)
for group_id in group_ids or []:
group_user_ids = await Groups.get_group_user_ids_by_id(group_id)
group_user_ids = await Groups.get_group_user_ids_by_id(group_id, include_inherited=True)
users.update(group_user_ids)
return users
@ -393,7 +393,9 @@ class ChannelTable:
async def get_channels_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[ChannelModel]:
async with get_async_db_context(db) as db:
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)]
user_group_ids = [
group.id for group in await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
]
result = await db.execute(
select(Channel)
@ -737,7 +739,9 @@ class ChannelTable:
return []
# Preload user's group membership
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id, db=db)]
user_group_ids = [
g.id for g in await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
]
allowed_channels = []
@ -813,7 +817,9 @@ class ChannelTable:
stmt = select(Channel).filter(Channel.id == id)
# Determine user groups
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)]
user_group_ids = [
group.id for group in await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
]
# Apply ACL rules
stmt = self._has_permission(

View file

@ -527,7 +527,7 @@ class ChatMessageTable:
db: Optional[AsyncSession] = None,
) -> dict[str, int]:
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
from open_webui.models.groups import group_user_memberships
stmt = select(ChatMessage.model_id, func.count(ChatMessage.id).label('count')).filter(
ChatMessage.role == 'assistant',
@ -539,7 +539,7 @@ class ChatMessageTable:
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
stmt = stmt.group_by(ChatMessage.model_id)
@ -555,7 +555,7 @@ class ChatMessageTable:
) -> dict[str, dict]:
"""Count distinct users and chats per model."""
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
from open_webui.models.groups import group_user_memberships
stmt = select(
ChatMessage.model_id,
@ -571,7 +571,7 @@ class ChatMessageTable:
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
stmt = stmt.group_by(ChatMessage.model_id)
@ -593,7 +593,7 @@ class ChatMessageTable:
) -> dict[str, dict]:
"""Aggregate token usage by model using database-level aggregation."""
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
from open_webui.models.groups import group_user_memberships
# We need the dialect to determine JSON extraction syntax
# For async sessions, access via get_bind()
@ -618,7 +618,7 @@ class ChatMessageTable:
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
stmt = stmt.group_by(ChatMessage.model_id)
@ -643,7 +643,7 @@ class ChatMessageTable:
) -> dict[str, dict]:
"""Aggregate token usage by user using database-level aggregation."""
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
from open_webui.models.groups import group_user_memberships
bind = await db.connection()
dialect = bind.dialect.name
@ -666,7 +666,7 @@ class ChatMessageTable:
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
stmt = stmt.group_by(ChatMessage.user_id)
@ -917,7 +917,7 @@ class ChatMessageTable:
db: Optional[AsyncSession] = None,
) -> dict[str, int]:
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
from open_webui.models.groups import group_user_memberships
stmt = select(ChatMessage.user_id, func.count(ChatMessage.id).label('count')).filter(
ChatMessage.role == 'assistant',
@ -928,7 +928,7 @@ class ChatMessageTable:
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
stmt = stmt.group_by(ChatMessage.user_id)
@ -943,7 +943,7 @@ class ChatMessageTable:
db: Optional[AsyncSession] = None,
) -> dict[str, int]:
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
from open_webui.models.groups import group_user_memberships
stmt = select(ChatMessage.chat_id, func.count(ChatMessage.id).label('count')).filter(
ChatMessage.role == 'assistant',
@ -954,7 +954,7 @@ class ChatMessageTable:
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
stmt = stmt.group_by(ChatMessage.chat_id)
@ -972,7 +972,7 @@ class ChatMessageTable:
async with get_async_db_context(db) as db:
from datetime import datetime, timedelta
from open_webui.models.groups import GroupMember
from open_webui.models.groups import group_user_memberships
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == 'assistant',
@ -984,7 +984,7 @@ class ChatMessageTable:
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
result = await db.execute(stmt)
@ -1021,7 +1021,7 @@ class ChatMessageTable:
async with get_async_db_context(db) as db:
from datetime import datetime, timedelta
from open_webui.models.groups import GroupMember
from open_webui.models.groups import group_user_memberships
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == 'assistant',
@ -1033,7 +1033,7 @@ class ChatMessageTable:
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
result = await db.execute(stmt)

View file

@ -2,9 +2,10 @@ import logging
import time
import uuid
from typing import Optional
from contextlib import asynccontextmanager
from open_webui.env import DEFAULT_GROUP_SHARE_PERMISSION
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.internal.db import Base, JSONField, get_async_db_context, get_async_db
from open_webui.models.access_grants import AccessGrant
from open_webui.models.files import FileMetadataResponse
from pydantic import BaseModel, ConfigDict
@ -23,6 +24,7 @@ from sqlalchemy import (
or_,
select,
update,
text,
)
from sqlalchemy.ext.asyncio import AsyncSession
@ -38,6 +40,10 @@ log = logging.getLogger(__name__)
class Group(Base):
__tablename__ = 'group'
parent_group_id = Column(
Text, ForeignKey('group.id', name='fk_group_parent', ondelete='SET NULL'), nullable=True, index=True
)
id = Column(Text, unique=True, primary_key=True)
user_id = Column(Text)
@ -54,6 +60,7 @@ class Group(Base):
class GroupModel(BaseModel):
parent_group_id: Optional[str] = None
id: str
user_id: str
@ -105,6 +112,7 @@ class GroupResponse(GroupModel):
class GroupInfoResponse(BaseModel):
parent_group_id: Optional[str] = None
id: str
user_id: str
name: str
@ -115,6 +123,7 @@ class GroupInfoResponse(BaseModel):
class GroupForm(BaseModel):
parent_group_id: Optional[str] = None
name: str
description: str
permissions: Optional[dict] = None
@ -134,6 +143,95 @@ class GroupListResponse(BaseModel):
total: int = 0
class GroupHierarchyError(ValueError):
def __init__(self, message: str, status_code: int = 400):
super().__init__(message)
self.status_code = status_code
def ancestor_groups(group_ids):
"""Identifier-only recursion: UNION also terminates on externally introduced cycles."""
chain = select(Group.id.label('group_id')).where(Group.id.in_(group_ids)).cte(recursive=True)
return chain.union(
select(Group.parent_group_id).join(chain, Group.id == chain.c.group_id).where(Group.parent_group_id.isnot(None))
)
def descendant_groups(group_ids):
chain = (
select(Group.id.label('root_id'), Group.id.label('group_id')).where(Group.id.in_(group_ids)).cte(recursive=True)
)
return chain.union(select(chain.c.root_id, Group.id).join(chain, Group.parent_group_id == chain.c.group_id))
def user_group_memberships(user_ids, include_inherited=False):
direct = select(GroupMember.user_id, GroupMember.group_id).where(GroupMember.user_id.in_(user_ids))
if not include_inherited:
return direct.subquery()
chain = direct.cte(recursive=True)
return chain.union(
select(chain.c.user_id, Group.parent_group_id)
.join(Group, Group.id == chain.c.group_id)
.where(Group.parent_group_id.isnot(None))
)
def group_user_memberships(group_ids, include_inherited=False):
if not include_inherited:
return select(GroupMember.group_id, GroupMember.user_id).where(GroupMember.group_id.in_(group_ids)).subquery()
descendants = descendant_groups(group_ids)
return (
select(descendants.c.root_id.label('group_id'), GroupMember.user_id)
.join(GroupMember, GroupMember.group_id == descendants.c.group_id)
.distinct()
.subquery()
)
@asynccontextmanager
async def hierarchy_transaction():
# Own the session: callers may already have an unrelated read transaction.
async with get_async_db() as db:
try:
if db.bind.dialect.name == 'sqlite':
await db.execute(text('BEGIN IMMEDIATE'))
elif db.bind.dialect.name == 'postgresql':
await db.execute(text('SELECT pg_advisory_xact_lock(731947205)'))
else:
raise RuntimeError('Group hierarchy requires SQLite or PostgreSQL')
yield db
await db.commit()
except Exception:
await db.rollback()
raise
async def refresh_group_sessions(user_ids):
if not user_ids:
return
# Import lazily to avoid models/socket import cycles. A committed write must not
# be reported as failed just because a client has already disconnected.
from open_webui.socket.main import disconnect_user_sessions
for user_id in set(user_ids):
try:
await disconnect_user_sessions(user_id, refresh_access=True)
except Exception:
log.exception('Unable to refresh group access for user %s', user_id)
async def validate_parent(db, group_id, parent_id):
if parent_id is None:
return
if not parent_id:
raise GroupHierarchyError('Parent group must be a group ID or null.')
if not await db.get(Group, parent_id):
raise GroupHierarchyError('Parent group not found.', 404)
ancestors = ancestor_groups([parent_id])
if (await db.execute(select(ancestors.c.group_id).where(ancestors.c.group_id == group_id))).first():
raise GroupHierarchyError('A group cannot be its own parent or a descendant of itself.')
class GroupTable:
def _ensure_default_share_config(self, group_data: dict) -> dict:
"""Ensure the group data dict has a default share config if not already set."""
@ -148,30 +246,20 @@ class GroupTable:
async def insert_new_group(
self, user_id: str, form_data: GroupForm, db: Optional[AsyncSession] = None
) -> Optional[GroupModel]:
async with get_async_db_context(db) as db:
async with hierarchy_transaction() as session:
await validate_parent(session, None, form_data.parent_group_id)
group_data = self._ensure_default_share_config(form_data.model_dump(exclude_none=True))
group = GroupModel(
**{
**group_data,
'id': str(uuid.uuid4()),
'user_id': user_id,
'created_at': int(time.time()),
'updated_at': int(time.time()),
}
group = Group(
**group_data,
id=str(uuid.uuid4()),
user_id=user_id,
created_at=int(time.time()),
updated_at=int(time.time()),
)
try:
result = Group(**group.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return GroupModel.model_validate(result)
else:
return None
except Exception:
return None
session.add(group)
await session.flush()
result = GroupModel.model_validate(group)
return result
async def get_all_groups(self, db: Optional[AsyncSession] = None) -> list[GroupModel]:
async with get_async_db_context(db) as db:
@ -217,7 +305,7 @@ class GroupTable:
)
if member_id:
member_groups_select = select(GroupMember.group_id).where(GroupMember.user_id == member_id)
member_groups_select = select(user_group_memberships([member_id], True).c.group_id)
members_only_and_is_member = and_(
json_share_lower == 'members',
Group.id.in_(member_groups_select),
@ -232,7 +320,7 @@ class GroupTable:
# Only apply member_id filter when share filter is NOT present
if 'member_id' in filter:
stmt = stmt.filter(
Group.id.in_(select(GroupMember.group_id).where(GroupMember.user_id == filter['member_id']))
Group.id.in_(select(user_group_memberships([filter['member_id']], True).c.group_id))
)
result = await db.execute(stmt.order_by(Group.updated_at.desc()))
@ -263,7 +351,7 @@ class GroupTable:
stmt = stmt.filter(Group.name.ilike(f'%{filter["query"]}%'))
if 'member_id' in filter:
stmt = stmt.filter(
Group.id.in_(select(GroupMember.group_id).where(GroupMember.user_id == filter['member_id']))
Group.id.in_(select(user_group_memberships([filter['member_id']], True).c.group_id))
)
if 'share' in filter:
@ -303,112 +391,100 @@ class GroupTable:
'total': total,
}
async def get_groups_by_member_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[GroupModel]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(Group)
.join(GroupMember, GroupMember.group_id == Group.id)
.filter(GroupMember.user_id == user_id)
.order_by(Group.updated_at.desc())
)
return [GroupModel.model_validate(group) for group in result.scalars().all()]
async def get_groups_by_member_id(
self, user_id: str, db: Optional[AsyncSession] = None, *, include_inherited=False
) -> list[GroupModel]:
return (await self.get_groups_by_member_ids([user_id], db=db, include_inherited=include_inherited))[user_id]
async def get_groups_by_member_ids(
self, user_ids: list[str], db: Optional[AsyncSession] = None
self, user_ids: list[str], db: Optional[AsyncSession] = None, *, include_inherited=False
) -> dict[str, list[GroupModel]]:
"""Fetch groups for multiple users in a single query to avoid N+1."""
groups = {uid: [] for uid in user_ids}
if not user_ids:
return groups
memberships = user_group_memberships(user_ids, include_inherited)
async with get_async_db_context(db) as db:
# Query GroupMember joined with Group, filtering by user_ids
result = await db.execute(
select(GroupMember.user_id, Group)
.join(Group, Group.id == GroupMember.group_id)
.filter(GroupMember.user_id.in_(user_ids))
.order_by(Group.updated_at.desc())
rows = await db.execute(
select(memberships.c.user_id, Group)
.join(Group, Group.id == memberships.c.group_id)
.order_by(Group.updated_at.desc(), Group.id)
)
rows = result.all()
for uid, group in rows:
groups[uid].append(GroupModel.model_validate(group))
return groups
# Group groups by user_id
user_groups: dict[str, list[GroupModel]] = {uid: [] for uid in user_ids}
for user_id, group in rows:
user_groups[user_id].append(GroupModel.model_validate(group))
return user_groups
async def get_ancestor_ids(self, group_id: str, db: Optional[AsyncSession] = None) -> set[str]:
chain = ancestor_groups([group_id])
async with get_async_db_context(db) as db:
return set((await db.execute(select(chain.c.group_id))).scalars())
async def get_group_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[GroupModel]:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Group).filter_by(id=id))
result = await db.execute(select(Group).filter_by(id=id).execution_options(populate_existing=True))
group = result.scalars().first()
return GroupModel.model_validate(group) if group else None
except Exception:
return None
async def get_group_user_ids_by_id(self, id: str, db: Optional[AsyncSession] = None) -> list[str]:
async with get_async_db_context(db) as db:
result = await db.execute(select(GroupMember.user_id).filter(GroupMember.group_id == id))
members = result.all()
if not members:
return []
return [m[0] for m in members]
async def get_group_user_ids_by_id(
self, id: str, db: Optional[AsyncSession] = None, *, include_inherited=False
) -> list[str]:
return (await self.get_group_user_ids_by_ids([id], db=db, include_inherited=include_inherited))[id]
async def get_group_user_ids_by_ids(
self, group_ids: list[str], db: Optional[AsyncSession] = None
self, group_ids: list[str], db: Optional[AsyncSession] = None, *, include_inherited=False
) -> dict[str, list[str]]:
users = {gid: [] for gid in group_ids}
if not group_ids:
return users
memberships = group_user_memberships(group_ids, include_inherited)
async with get_async_db_context(db) as db:
result = await db.execute(
select(GroupMember.group_id, GroupMember.user_id).filter(GroupMember.group_id.in_(group_ids))
)
members = result.all()
group_user_ids: dict[str, list[str]] = {group_id: [] for group_id in group_ids}
for group_id, user_id in members:
group_user_ids[group_id].append(user_id)
return group_user_ids
for gid, uid in await db.execute(select(memberships)):
users[gid].append(uid)
return users
async def set_group_user_ids_by_id(
self, group_id: str, user_ids: list[str], db: Optional[AsyncSession] = None
) -> None:
async with get_async_db_context(db) as db:
# Delete existing members
await db.execute(delete(GroupMember).filter(GroupMember.group_id == group_id))
# Insert new members
now = int(time.time())
new_members = [
GroupMember(
id=str(uuid.uuid4()),
group_id=group_id,
user_id=user_id,
created_at=now,
updated_at=now,
async with hierarchy_transaction() as session:
if not await session.get(Group, group_id):
raise GroupHierarchyError('Group not found.', 404)
previous = set(
(await session.execute(select(GroupMember.user_id).where(GroupMember.group_id == group_id))).scalars()
)
requested = set(user_ids)
await session.execute(
delete(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id.in_(previous - requested)
)
for user_id in user_ids
]
)
now = int(time.time())
session.add_all(
[
GroupMember(id=str(uuid.uuid4()), group_id=group_id, user_id=uid, created_at=now, updated_at=now)
for uid in requested - previous
]
)
await session.execute(update(Group).where(Group.id == group_id).values(updated_at=now))
await refresh_group_sessions(previous ^ requested)
db.add_all(new_members)
await db.commit()
async def get_group_member_count_by_id(
self, id: str, db: Optional[AsyncSession] = None, *, include_inherited=False
) -> int:
return (await self.get_group_member_counts_by_ids([id], db=db, include_inherited=include_inherited)).get(id, 0)
async def get_group_member_count_by_id(self, id: str, db: Optional[AsyncSession] = None) -> int:
async with get_async_db_context(db) as db:
result = await db.execute(select(func.count(GroupMember.user_id)).filter(GroupMember.group_id == id))
count = result.scalar()
return count if count else 0
async def get_group_member_counts_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> dict[str, int]:
async def get_group_member_counts_by_ids(
self, ids: list[str], db: Optional[AsyncSession] = None, *, include_inherited=False
) -> dict[str, int]:
if not ids:
return {}
memberships = group_user_memberships(ids, include_inherited)
async with get_async_db_context(db) as db:
result = await db.execute(
select(GroupMember.group_id, func.count(GroupMember.user_id))
.filter(GroupMember.group_id.in_(ids))
.group_by(GroupMember.group_id)
rows = await db.execute(
select(memberships.c.group_id, func.count(memberships.c.user_id)).group_by(memberships.c.group_id)
)
rows = result.all()
return {group_id: count for group_id, count in rows}
return dict(rows.all())
async def update_group_by_id(
self,
@ -416,71 +492,75 @@ class GroupTable:
form_data: GroupUpdateForm,
overwrite: bool = False,
db: Optional[AsyncSession] = None,
*,
changes: Optional[dict] = None,
) -> Optional[GroupModel]:
try:
async with get_async_db_context(db) as db:
await db.execute(
update(Group)
.filter_by(id=id)
.values(
**form_data.model_dump(exclude_none=True),
updated_at=int(time.time()),
)
affected = []
async with hierarchy_transaction() as session:
group = await session.get(Group, id)
if group is None:
raise GroupHierarchyError('Group not found.', 404)
values = form_data.model_dump(exclude_none=True)
if 'parent_group_id' in form_data.model_fields_set:
await validate_parent(session, id, form_data.parent_group_id)
values['parent_group_id'] = form_data.parent_group_id
parent_changed = values.get('parent_group_id', group.parent_group_id) != group.parent_group_id
if changes is not None:
changes.update(
old_parent_group_id=group.parent_group_id,
parent_group_id=values.get('parent_group_id', group.parent_group_id),
)
await db.commit()
return await self.get_group_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
return None
if parent_changed or ('permissions' in values and values['permissions'] != group.permissions):
members = group_user_memberships([id], True)
affected = list((await session.execute(select(members.c.user_id))).scalars())
for key, value in values.items():
setattr(group, key, value)
group.updated_at = int(time.time())
await session.flush()
result = GroupModel.model_validate(group)
await refresh_group_sessions(affected)
return result
async def delete_group_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
await db.execute(delete(Group).filter_by(id=id))
await db.execute(delete(AccessGrant).filter_by(principal_type='group', principal_id=id))
await db.commit()
return True
except Exception:
await db.rollback()
return False
async def delete_group_by_id(
self, id: str, db: Optional[AsyncSession] = None, *, changes: Optional[dict] = None
) -> bool:
async with hierarchy_transaction() as session:
group = await session.get(Group, id)
if group is None:
raise GroupHierarchyError('Group not found.', 404)
members = group_user_memberships([id], True)
affected = list((await session.execute(select(members.c.user_id))).scalars())
children = list((await session.execute(select(Group.id).where(Group.parent_group_id == id))).scalars())
if changes is not None:
changes.update(parent_group_id=group.parent_group_id, promoted_child_ids=children)
await session.execute(
update(Group)
.where(Group.parent_group_id == id)
.values(parent_group_id=group.parent_group_id, updated_at=int(time.time()))
)
await session.execute(delete(GroupMember).where(GroupMember.group_id == id))
await session.execute(delete(AccessGrant).filter_by(principal_type='group', principal_id=id))
await session.execute(delete(Group).where(Group.id == id))
await refresh_group_sessions(affected)
return True
async def delete_all_groups(self, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
await db.execute(delete(Group))
await db.execute(delete(AccessGrant).filter_by(principal_type='group'))
await db.commit()
return True
except Exception:
await db.rollback()
return False
async with hierarchy_transaction() as session:
affected = list((await session.execute(select(GroupMember.user_id).distinct())).scalars())
await session.execute(update(Group).values(parent_group_id=None))
await session.execute(delete(GroupMember))
await session.execute(delete(AccessGrant).filter_by(principal_type='group'))
await session.execute(delete(Group))
await refresh_group_sessions(affected)
return True
async def remove_user_from_all_groups(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
# Find all groups the user belongs to
result = await db.execute(
select(Group)
.join(GroupMember, GroupMember.group_id == Group.id)
.filter(GroupMember.user_id == user_id)
)
groups = result.scalars().all()
# Remove the user from each group
for group in groups:
await db.execute(
delete(GroupMember).filter(GroupMember.group_id == group.id, GroupMember.user_id == user_id)
)
await db.execute(update(Group).filter_by(id=group.id).values(updated_at=int(time.time())))
await db.commit()
return True
except Exception:
await db.rollback()
return False
async with hierarchy_transaction() as session:
ids = select(GroupMember.group_id).where(GroupMember.user_id == user_id)
await session.execute(update(Group).where(Group.id.in_(ids)).values(updated_at=int(time.time())))
await session.execute(delete(GroupMember).where(GroupMember.user_id == user_id))
await refresh_group_sessions([user_id])
return True
async def create_groups_by_group_names(
self, user_id: str, group_names: list[str], db: Optional[AsyncSession] = None
@ -521,133 +601,74 @@ class GroupTable:
async def sync_groups_by_group_names(
self, user_id: str, group_names: list[str], db: Optional[AsyncSession] = None
) -> bool:
async with get_async_db_context(db) as db:
try:
now = int(time.time())
# 1. Groups that SHOULD contain the user
result = await db.execute(select(Group).filter(Group.name.in_(group_names)))
target_groups = result.scalars().all()
target_group_ids = {g.id for g in target_groups}
# 2. Groups the user is CURRENTLY in
result = await db.execute(
select(Group)
.join(GroupMember, GroupMember.group_id == Group.id)
.filter(GroupMember.user_id == user_id)
)
existing_group_ids = {g.id for g in result.scalars().all()}
# 3. Determine adds + removals
groups_to_add = target_group_ids - existing_group_ids
groups_to_remove = existing_group_ids - target_group_ids
# 4. Remove in one bulk delete
if groups_to_remove:
await db.execute(
delete(GroupMember).filter(
GroupMember.user_id == user_id,
GroupMember.group_id.in_(groups_to_remove),
)
)
await db.execute(update(Group).filter(Group.id.in_(groups_to_remove)).values(updated_at=now))
# 5. Bulk insert missing memberships
for group_id in groups_to_add:
db.add(
GroupMember(
id=str(uuid.uuid4()),
group_id=group_id,
user_id=user_id,
created_at=now,
updated_at=now,
)
)
if groups_to_add:
await db.execute(update(Group).filter(Group.id.in_(groups_to_add)).values(updated_at=now))
await db.commit()
return True
except Exception as e:
log.exception(e)
await db.rollback()
return False
async with hierarchy_transaction() as session:
target = set((await session.execute(select(Group.id).where(Group.name.in_(group_names)))).scalars())
previous = set(
(await session.execute(select(GroupMember.group_id).where(GroupMember.user_id == user_id))).scalars()
)
await session.execute(
delete(GroupMember).where(GroupMember.user_id == user_id, GroupMember.group_id.in_(previous - target))
)
now = int(time.time())
session.add_all(
[
GroupMember(id=str(uuid.uuid4()), group_id=gid, user_id=user_id, created_at=now, updated_at=now)
for gid in target - previous
]
)
await session.execute(update(Group).where(Group.id.in_(previous ^ target)).values(updated_at=now))
if previous != target:
await refresh_group_sessions([user_id])
return True
async def add_users_to_group(
self,
id: str,
user_ids: Optional[list[str]] = None,
db: Optional[AsyncSession] = None,
self, id: str, user_ids: Optional[list[str]] = None, db: Optional[AsyncSession] = None
) -> Optional[GroupModel]:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Group).filter_by(id=id))
group = result.scalars().first()
if not group:
return None
now = int(time.time())
for user_id in user_ids or []:
try:
db.add(
GroupMember(
id=str(uuid.uuid4()),
group_id=id,
user_id=user_id,
created_at=now,
updated_at=now,
)
)
await db.flush() # Detect unique constraint violation early
except Exception:
await db.rollback() # Clear failed INSERT
continue # Duplicate → ignore
group.updated_at = now
await db.commit()
await db.refresh(group)
return GroupModel.model_validate(group)
except Exception as e:
log.exception(e)
return None
async with hierarchy_transaction() as session:
group = await session.get(Group, id)
if group is None:
raise GroupHierarchyError('Group not found.', 404)
previous = set(
(await session.execute(select(GroupMember.user_id).where(GroupMember.group_id == id))).scalars()
)
added = set(user_ids or []) - previous
now = int(time.time())
session.add_all(
[
GroupMember(id=str(uuid.uuid4()), group_id=id, user_id=uid, created_at=now, updated_at=now)
for uid in added
]
)
group.updated_at = now
await session.flush()
result = GroupModel.model_validate(group)
await refresh_group_sessions(added)
return result
async def remove_users_from_group(
self,
id: str,
user_ids: Optional[list[str]] = None,
db: Optional[AsyncSession] = None,
self, id: str, user_ids: Optional[list[str]] = None, db: Optional[AsyncSession] = None
) -> Optional[GroupModel]:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Group).filter_by(id=id))
group = result.scalars().first()
if not group:
return None
if not user_ids:
return GroupModel.model_validate(group)
# Remove users from group_member in batch
await db.execute(
delete(GroupMember).filter(GroupMember.group_id == id, GroupMember.user_id.in_(user_ids))
)
# Update group timestamp
group.updated_at = int(time.time())
await db.commit()
await db.refresh(group)
return GroupModel.model_validate(group)
except Exception as e:
log.exception(e)
return None
async with hierarchy_transaction() as session:
group = await session.get(Group, id)
if group is None:
raise GroupHierarchyError('Group not found.', 404)
removed = list(
(
await session.execute(
select(GroupMember.user_id).where(
GroupMember.group_id == id, GroupMember.user_id.in_(user_ids or [])
)
)
).scalars()
)
await session.execute(
delete(GroupMember).where(GroupMember.group_id == id, GroupMember.user_id.in_(removed))
)
group.updated_at = int(time.time())
await session.flush()
result = GroupModel.model_validate(group)
await refresh_group_sessions(removed)
return result
Groups = GroupTable()

View file

@ -481,7 +481,7 @@ class KnowledgeTable:
if knowledge.user_id == user_id:
return True
if user_group_ids is None:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = {group.id for group in user_groups}
return await AccessGrants.has_access(
user_id=user_id,

View file

@ -252,7 +252,10 @@ class ModelsTable:
if writable_by_user_id:
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(writable_by_user_id, db=db)
group.id
for group in await Groups.get_groups_by_member_id(
writable_by_user_id, db=db, include_inherited=True
)
}
stmt = self._has_permission(
db, stmt, {'user_id': writable_by_user_id, 'group_ids': user_group_ids}, permission='write'
@ -475,7 +478,7 @@ class ModelsTable:
)
if not is_admin:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = [group.id for group in user_groups]
filter_dict = {'user_id': user_id}

View file

@ -313,7 +313,7 @@ class NoteTable:
db: Optional[AsyncSession] = None,
) -> list[NoteModel]:
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = [group.id for group in user_groups]
stmt = select(Note).order_by(Note.updated_at.desc())
@ -400,7 +400,7 @@ class NoteTable:
db: Optional[AsyncSession] = None,
) -> list[NoteModel]:
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = [group.id for group in user_groups]
stmt = (

View file

@ -241,7 +241,7 @@ class PromptsTable:
self, user_id: str, permission: str = 'write', db: AsyncSession | None = None
) -> list[PromptUserResponse]:
async with get_async_db_context(db) as session:
user_groups = await Groups.get_groups_by_member_id(user_id, db=session)
user_groups = await Groups.get_groups_by_member_id(user_id, db=session, include_inherited=True)
user_group_ids = [group.id for group in user_groups]
query = select(Prompt).filter(Prompt.is_active == True).order_by(Prompt.updated_at.desc())
@ -699,7 +699,7 @@ class PromptsTable:
async def get_tags_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> list[str]:
try:
async with get_async_db_context(db) as session:
user_groups = await Groups.get_groups_by_member_id(user_id, db=session)
user_groups = await Groups.get_groups_by_member_id(user_id, db=session, include_inherited=True)
user_group_ids = [group.id for group in user_groups]
query = select(Prompt.tags).filter(Prompt.is_active == True)

View file

@ -177,7 +177,9 @@ class SkillsTable:
stmt = stmt.filter(Skill.id.in_(ids))
if user_id is not None:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
}
stmt = AccessGrants.has_permission_filter(
db=db,
query=stmt,

View file

@ -189,7 +189,10 @@ class ToolsTable:
if user_id is not None:
if user_group_ids is None:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
user_group_ids = {
group.id
for group in await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
}
stmt = AccessGrants.has_permission_filter(
db=db,
query=stmt,
@ -235,7 +238,7 @@ class ToolsTable:
defer_content: bool = False,
db: AsyncSession | None = None,
) -> list[ToolUserModel]:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = {group.id for group in user_groups}
return await self.get_tools(
defer_content=defer_content,

View file

@ -549,7 +549,7 @@ class UsersTable:
async with get_async_db_context(db) as session:
# Deferred imports to avoid circular dependencies
from open_webui.models.channels import ChannelMember
from open_webui.models.groups import GroupMember
from open_webui.models.groups import GroupMember, group_user_memberships
# Join GroupMember so we can order by group_id when requested
stmt = select(User)
@ -587,14 +587,8 @@ class UsersTable:
stmt = stmt.filter(User.id.in_(user_ids))
if group_ids:
stmt = stmt.filter(
exists(
select(GroupMember.id).where(
GroupMember.user_id == User.id,
GroupMember.group_id.in_(group_ids),
)
)
)
memberships = group_user_memberships(group_ids, True)
stmt = stmt.filter(User.id.in_(select(memberships.c.user_id)))
roles = filter.get('roles')
if roles:

View file

@ -67,7 +67,7 @@ async def _check_calendar_access(calendar_id: str, user: UserModel, permission:
raise HTTPException(status_code=404, detail='Calendar not found')
if cal.user_id == user.id or user.role == 'admin':
return cal
user_groups = await Groups.get_groups_by_member_id(user.id)
user_groups = await Groups.get_groups_by_member_id(user.id, include_inherited=True)
user_group_ids = [g.id for g in user_groups]
if await AccessGrants.has_access(
user_id=user.id,

View file

@ -126,7 +126,7 @@ async def get_channel_member_user_ids(
user_ids = permitted_ids.get('user_ids') or []
group_ids = permitted_ids.get('group_ids') or []
if group_ids:
for member_ids in (await Groups.get_group_user_ids_by_ids(group_ids, db=db)).values():
for member_ids in (await Groups.get_group_user_ids_by_ids(group_ids, db=db, include_inherited=True)).values():
user_ids.extend(member_ids)
return list(dict.fromkeys([*user_ids, channel.user_id]))

View file

@ -109,7 +109,9 @@ async def get_folders(
user_group_ids = None
if user.role != 'admin' and any(folder.data and 'files' in folder.data for folder in folders):
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
}
# Verify folder data integrity
folder_list = []
@ -244,7 +246,7 @@ async def get_shared_folders(
):
"""Get all folders shared with the current user (not owned by them)."""
await check_folders_permission(request, user, db=db)
groups = await Groups.get_groups_by_member_id(user.id, db=db)
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
group_ids = {g.id for g in groups}
folder_perms = await Folders.get_shared_folder_ids_for_user(user.id, group_ids, db=db)

View file

@ -1,16 +1,22 @@
import logging
import os
from pathlib import Path
from typing import Optional
from typing import Optional, Literal
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, status, Query
from open_webui.config import CACHE_DIR
from open_webui.models.config import Config
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
from open_webui.models.groups import (
GroupForm,
GroupHierarchyError,
Group,
GroupMember,
group_user_memberships,
descendant_groups,
GroupInfoResponse,
GroupResponse,
Groups,
@ -20,9 +26,13 @@ from open_webui.models.groups import (
from open_webui.models.knowledge import Knowledges
from open_webui.models.models import Models
from open_webui.models.tools import Tools
from open_webui.models.users import UserInfoResponse, Users
from open_webui.models.users import UserInfoResponse, Users, User
from open_webui.utils.auth import get_admin_user, get_verified_user
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from pydantic import BaseModel
from open_webui.utils.access_control import combine_permissions
from open_webui.utils.json_codec import JSONCodec
log = logging.getLogger(__name__)
@ -83,6 +93,8 @@ async def create_new_group(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Error creating group'),
)
except GroupHierarchyError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
except HTTPException:
raise
except Exception as e:
@ -108,7 +120,7 @@ async def get_group_by_id(id: str, user=Depends(get_admin_user), db: AsyncSessio
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
@ -123,7 +135,7 @@ async def get_group_info_by_id(id: str, user=Depends(get_verified_user), db: Asy
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
@ -149,7 +161,7 @@ async def export_group_by_id(id: str, user=Depends(get_admin_user), db: AsyncSes
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
@ -186,14 +198,15 @@ async def update_group_by_id(
db: AsyncSession = Depends(get_async_session),
):
try:
group = await Groups.update_group_by_id(id, form_data, db=db)
changes = {}
group = await Groups.update_group_by_id(id, form_data, db=db, changes=changes)
if group:
await publish_event(
request,
EVENTS.GROUP_UPDATED,
actor=user,
subject_id=id,
data={'name': group.name},
data={'name': group.name, **changes},
)
return GroupResponse(
**group.model_dump(),
@ -204,6 +217,8 @@ async def update_group_by_id(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Error updating group'),
)
except GroupHierarchyError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
except HTTPException:
raise
except Exception as e:
@ -249,6 +264,8 @@ async def add_user_to_group(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Error adding users to group'),
)
except GroupHierarchyError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
except HTTPException:
raise
except Exception as e:
@ -286,6 +303,8 @@ async def remove_users_from_group(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Error removing users from group'),
)
except GroupHierarchyError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
except HTTPException:
raise
except Exception as e:
@ -306,20 +325,32 @@ async def delete_group_by_id(
request: Request, id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
):
try:
result = await Groups.delete_group_by_id(id, db=db)
changes = {}
result = await Groups.delete_group_by_id(id, db=db, changes=changes)
if result:
await publish_event(
request,
EVENTS.GROUP_DELETED,
actor=user,
subject_id=id,
data=changes,
)
for child_id in changes['promoted_child_ids']:
await publish_event(
request,
EVENTS.GROUP_UPDATED,
actor=user,
subject_id=child_id,
data={'old_parent_group_id': id, 'parent_group_id': changes['parent_group_id']},
)
return result
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Error deleting group'),
)
except GroupHierarchyError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
except HTTPException:
raise
except Exception as e:
@ -349,7 +380,7 @@ async def preview_group_access(
detail=ERROR_MESSAGES.NOT_FOUND,
)
group_ids = {group.id}
group_ids = await Groups.get_ancestor_ids(group.id, db=db)
# Batch-check accessible resources using existing AccessGrants
all_models = await Models.get_all_models(db=db)
@ -384,6 +415,14 @@ async def preview_group_access(
active_models = [m for m in all_models if m.is_active]
ancestors = (await db.execute(select(Group).where(Group.id.in_(group_ids - {id})))).scalars().all()
inherited_permissions = JSONCodec.loads(JSONCodec.dumps(await Config.get('user.permissions') or {}))
for ancestor in ancestors:
inherited_permissions = combine_permissions(inherited_permissions, ancestor.permissions or {})
effective_permissions = combine_permissions(
JSONCodec.loads(JSONCodec.dumps(inherited_permissions)), group.permissions or {}
)
return {
'group': {'id': group.id, 'name': group.name},
'models': {
@ -399,4 +438,72 @@ async def preview_group_access(
'total': len(all_tools),
},
'permissions': group.permissions or {},
'inherited_permissions': inherited_permissions,
'effective_permissions': effective_permissions,
}
class GroupMemberInfo(UserInfoResponse):
membership_type: Literal['direct', 'inherited']
via_group_ids: list[str] = []
class GroupMembersResponse(BaseModel):
items: list[GroupMemberInfo]
total: int
counts: dict[str, int]
@router.get('/id/{id}/members', response_model=GroupMembersResponse)
async def inspect_group_members(
id: str,
membership: Literal['direct', 'inherited', 'effective'] = 'effective',
query: str = '',
page: int = Query(default=1, ge=1),
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
if not await db.get(Group, id):
raise HTTPException(status_code=404, detail='Group not found.')
direct = select(GroupMember.user_id).where(GroupMember.group_id == id)
effective = group_user_memberships([id], True)
direct_count = (await db.execute(select(func.count()).select_from(direct.subquery()))).scalar_one()
effective_count = (await db.execute(select(func.count()).select_from(effective))).scalar_one()
stmt = select(User).where(User.id.in_(select(effective.c.user_id)))
if membership == 'direct':
stmt = stmt.where(User.id.in_(direct))
elif membership == 'inherited':
stmt = stmt.where(User.id.not_in(direct))
if query:
stmt = stmt.where(or_(User.name.ilike(f'%{query}%'), User.email.ilike(f'%{query}%')))
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
users = (await db.execute(stmt.order_by(User.name, User.id).offset((page - 1) * 30).limit(30))).scalars().all()
descendant = descendant_groups([id])
sources = {u.id: [] for u in users}
if sources:
rows = await db.execute(
select(GroupMember.user_id, GroupMember.group_id).where(
GroupMember.user_id.in_(sources), GroupMember.group_id.in_(select(descendant.c.group_id))
)
)
for uid, gid in rows:
sources[uid].append(gid)
return GroupMembersResponse(
items=[
GroupMemberInfo(
id=u.id,
name=u.name,
email=u.email,
role=u.role,
membership_type='direct' if id in sources[u.id] else 'inherited',
via_group_ids=sorted(gid for gid in sources[u.id] if gid != id),
)
for u in users
],
total=total,
counts={
'direct': direct_count,
'effective': effective_count,
'inherited': effective_count - direct_count,
},
)

View file

@ -180,7 +180,7 @@ async def get_knowledge_bases(
skip = (page - 1) * limit
filter = {}
groups = await Groups.get_groups_by_member_id(user.id, db=db)
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
user_group_ids = {group.id for group in groups}
if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL:
@ -245,7 +245,7 @@ async def search_knowledge_bases(
if direction in {'asc', 'desc'}:
filter['direction'] = direction
groups = await Groups.get_groups_by_member_id(user.id, db=db)
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
user_group_ids = {group.id for group in groups}
if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL:
@ -301,7 +301,7 @@ async def search_knowledge_files(
if include_content:
filter['include_content'] = True
groups = await Groups.get_groups_by_member_id(user.id, db=db)
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
if groups:
filter['group_ids'] = [group.id for group in groups]

View file

@ -192,7 +192,7 @@ async def get_models(
filter['direction'] = direction
# Pre-fetch user group IDs once - used for both filter and write_access check
groups = await Groups.get_groups_by_member_id(user.id, db=db)
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
user_group_ids = {group.id for group in groups}
if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL:
@ -472,7 +472,7 @@ async def import_models(
# per-model has_access calls (N+1 avoidance).
existing_model_ids = list(existing_models.keys())
if user.role != 'admin' and existing_model_ids:
groups = await Groups.get_groups_by_member_id(user.id, db=db)
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
user_group_ids = {group.id for group in groups}
writable_model_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user.id,

View file

@ -201,7 +201,7 @@ async def search_notes(
filter['direction'] = direction
if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = await Groups.get_groups_by_member_id(user.id, db=db)
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
if groups:
filter['group_ids'] = [group.id for group in groups]

View file

@ -455,7 +455,7 @@ async def get_filtered_models(models, user, db=None):
"""Return only the models the given *user* is allowed to access."""
model_ids = [m['model'] for m in models.get('models', [])]
model_infos = {mi.id: mi for mi in await Models.get_models_by_ids(model_ids, db=db)}
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db)}
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)}
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user.id,
@ -1548,7 +1548,7 @@ async def get_openai_models(
if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL:
model_ids = [m['id'] for m in models]
model_infos = {mi.id: mi for mi in await Models.get_models_by_ids(model_ids, db=db)}
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db)}
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)}
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type='model',

View file

@ -683,7 +683,9 @@ 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 = {model_info.id: model_info for model_info in await Models.get_models_by_ids(model_ids, db=db)}
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
}
# Batch-fetch accessible resource IDs in a single query instead of N has_access calls
accessible_model_ids = await AccessGrants.get_accessible_resource_ids(

View file

@ -97,7 +97,7 @@ async def get_prompt_list(
filter['direction'] = direction
# Pre-fetch user group IDs once - used for both filter and write_access check
groups = await Groups.get_groups_by_member_id(user.id, db=db)
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
user_group_ids = {group.id for group in groups}
if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL):

View file

@ -14,12 +14,13 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from open_webui.config import OAUTH_PROVIDERS
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.env import SCIM_AUTH_PROVIDER
from open_webui.internal.db import get_async_session
from open_webui.models.groups import GroupModel, Groups
from open_webui.models.groups import GroupModel, Groups, GroupHierarchyError
from open_webui.models.users import UserModel, Users
from open_webui.utils.auth import (
decode_token,
@ -32,7 +33,21 @@ from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
router = APIRouter()
class SCIMGroupRoute(APIRoute):
def get_route_handler(self):
handler = super().get_route_handler()
async def handle(request):
try:
return await handler(request)
except GroupHierarchyError as error:
return scim_error(error.status_code, str(error), 'invalidValue')
return handle
router = APIRouter(route_class=SCIMGroupRoute)
# SCIM 2.0 Schema URIs
SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User'
@ -937,6 +952,19 @@ async def get_group(
return await group_to_scim(group, request, db=db)
async def validate_user_members(members, db):
ids = []
for member in members or []:
value = member if isinstance(member, dict) else member.model_dump(by_alias=True)
if value.get('type') not in (None, 'User') or '/Groups/' in (value.get('$ref') or ''):
raise GroupHierarchyError('Only direct User members are supported by SCIM.')
if not value.get('value'):
raise GroupHierarchyError('A member user ID is required.')
ids.append(value['value'])
if set(await Users.get_valid_user_ids(ids, db=db)) != set(ids):
raise GroupHierarchyError('One or more member users were not found.')
@router.post('/Groups', response_model=SCIMGroup, status_code=status.HTTP_201_CREATED)
async def create_group(
request: Request,
@ -945,6 +973,7 @@ async def create_group(
db: AsyncSession = Depends(get_async_session),
):
"""Create SCIM Group"""
await validate_user_members(group_data.members, db)
# Extract member IDs
member_ids = []
if group_data.members:
@ -1016,6 +1045,7 @@ async def update_group(
db: AsyncSession = Depends(get_async_session),
):
"""Update SCIM Group (full update)"""
await validate_user_members(group_data.members, db)
group = await Groups.get_group_by_id(group_id, db=db)
if not group:
raise HTTPException(
@ -1102,6 +1132,13 @@ async def patch_group(
added_member_ids = []
removed_member_ids = []
# Validate all requested assignments before applying any patch operation.
for operation in patch_data.Operations:
if operation.path == 'members' and operation.op.lower() in ('add', 'replace'):
if not isinstance(operation.value, list):
raise GroupHierarchyError('Members must be a list of users.')
await validate_user_members(operation.value, db)
for operation in patch_data.Operations:
op = operation.op.lower()
path = operation.path
@ -1184,7 +1221,8 @@ async def delete_group(
detail=f'Group {group_id} not found',
)
success = await Groups.delete_group_by_id(group_id, db=db)
changes = {}
success = await Groups.delete_group_by_id(group_id, db=db, changes=changes)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -1196,7 +1234,15 @@ async def delete_group(
EVENTS.GROUP_DELETED,
subject_id=group_id,
source='scim',
data={'name': group.name},
data={'name': group.name, **changes},
)
for child_id in changes['promoted_child_ids']:
await publish_event(
request,
EVENTS.GROUP_UPDATED,
subject_id=child_id,
source='scim',
data={'old_parent_group_id': group_id, 'parent_group_id': changes['parent_group_id']},
)
return None

View file

@ -86,7 +86,9 @@ async def get_skill_list(
filter['direction'] = direction
is_bypass_admin = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
}
if not is_bypass_admin:
filter['group_ids'] = user_group_ids

View file

@ -91,7 +91,7 @@ async def list_terminal_servers(request: Request, user=Depends(get_verified_user
return []
connections = await Config.get('terminal_server.connections', []) or []
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
return [
{
@ -129,7 +129,7 @@ async def proxy_terminal(
if not connection.get('enabled', True):
return JSONResponse({'error': 'Terminal server disabled'}, status_code=403)
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
if not await has_connection_access(user, connection, user_group_ids):
return JSONResponse({'error': 'Access denied'}, status_code=403)

View file

@ -81,7 +81,9 @@ async def get_tools(
tools = []
bypass_access_control = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
user_group_ids = (
set() if bypass_access_control else {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
set()
if bypass_access_control
else {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)}
)
# Local Tools
@ -236,7 +238,9 @@ async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depe
bypass_access_control = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
user_group_ids = (
set() if bypass_access_control else {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
set()
if bypass_access_control
else {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)}
)
tools = await Tools.get_tools(
defer_content=True,

View file

@ -167,8 +167,10 @@ async def search_users(
@router.get('/groups')
async def get_user_groups(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
return await Groups.get_groups_by_member_id(user.id, db=db)
async def get_user_groups(
include_inherited: bool = False, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
return await user_groups_response(user.id, include_inherited, db)
############################
@ -1108,9 +1110,12 @@ async def delete_user_by_id(
@router.get('/{user_id}/groups')
async def get_user_groups_by_id(
user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
user_id: str,
include_inherited: bool = False,
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
return await Groups.get_groups_by_member_id(user_id, db=db)
return await user_groups_response(user_id, include_inherited, db)
############################
@ -1133,7 +1138,7 @@ async def get_user_preview(
)
# Get all group IDs this user belongs to
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = {g.id for g in user_groups}
all_models = await Models.get_all_models(db=db)
@ -1189,3 +1194,12 @@ async def get_user_preview(
'total': len(all_tools),
},
}
async def user_groups_response(user_id, include_inherited, db):
direct = await Groups.get_groups_by_member_id(user_id, db=db)
if not include_inherited:
return direct
direct_ids = {g.id for g in direct}
effective = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
return [{**g.model_dump(), 'membership_type': 'direct' if g.id in direct_ids else 'inherited'} for g in effective]

View file

@ -434,7 +434,7 @@ async def leave_room_for_users(room: str, user_ids: list[str]):
log.debug('Failed to make session %s leave room %s: %s', sid, room, e)
async def disconnect_user_sessions(user_id: str):
async def disconnect_user_sessions(user_id: str, *, refresh_access: bool = False):
"""Disconnect all Socket.IO sessions belonging to a user.
Call this when a user's role is changed or the user is deleted so that
@ -444,6 +444,11 @@ async def disconnect_user_sessions(user_id: str):
"""
session_ids = get_session_ids_by_user_id(user_id)
for sid in session_ids:
if refresh_access:
try:
await sio.emit('access:updated', {}, to=sid)
except Exception:
log.exception('Failed to notify session %s about changed access', sid)
try:
await sio.disconnect(sid)
except Exception:

View file

@ -81,7 +81,7 @@ async def _has_write_access_to_note(note, user_id: str) -> bool:
from open_webui.models.access_grants import AccessGrants
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
return await AccessGrants.has_access(
user_id=user_id,
resource_type='note',
@ -1149,7 +1149,7 @@ async def search_notes(
try:
user_id = __user__.get('id')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
result = await Notes.search_notes(
user_id=user_id,
@ -1246,7 +1246,7 @@ async def view_note(
# Check access permission
user_id = __user__.get('id')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
from open_webui.models.access_grants import AccessGrants
@ -2067,7 +2067,7 @@ async def list_knowledge_bases(
from open_webui.models.knowledge import Knowledges
user_id = __user__.get('id')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
result = await Knowledges.search_knowledge_bases(
user_id,
@ -2127,7 +2127,7 @@ async def search_knowledge_bases(
from open_webui.models.knowledge import Knowledges
user_id = __user__.get('id')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
result = await Knowledges.search_knowledge_bases(
user_id,
@ -2194,7 +2194,7 @@ async def search_knowledge_files(
user_id = __user__.get('id')
user_role = __user__.get('role', 'user')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
# When model has attached knowledge, scope to attached KBs/files only
if __model_knowledge__:
@ -2663,7 +2663,7 @@ async def grep_knowledge_files(
user_id = __user__.get('id')
user_role = __user__.get('role', 'user')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
# Collect files to search
files_to_search = []
@ -2910,7 +2910,7 @@ async def view_knowledge_file(
user_id = __user__.get('id')
user_role = __user__.get('role', 'user')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
file = await Files.get_file_by_id(file_id)
if not file:
@ -3060,7 +3060,7 @@ async def list_knowledge(
user_id = __user__.get('id')
user_role = __user__.get('role', 'user')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
knowledge_bases = []
files = []
@ -3201,7 +3201,7 @@ async def query_knowledge_files(
user_id = __user__.get('id')
user_role = __user__.get('role', 'user')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None)
if not embedding_function:
@ -3398,7 +3398,7 @@ async def query_knowledge_bases(
from open_webui.routers.knowledge import KNOWLEDGE_BASES_COLLECTION
user_id = __user__.get('id')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None)
if not embedding_function:
return JSONCodec.dumps({'error': 'Embedding function not configured'})
@ -3526,7 +3526,9 @@ async def view_skill(
# Check user access
user_role = __user__.get('role', 'user')
if user_role != 'admin' and skill.user_id != user_id:
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [
group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)
]
if not await AccessGrants.has_access(
user_id=user_id,
resource_type='skill',
@ -4297,7 +4299,7 @@ async def create_calendar_event(
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
if not await AccessGrants.has_access(
user_id=user_id,
resource_type='calendar',
@ -4417,7 +4419,7 @@ async def update_calendar_event(
if not cal:
return JSONCodec.dumps({'error': 'Access denied'})
if cal.user_id != user_id and __user__.get('role') != 'admin':
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
if not await AccessGrants.has_access(
user_id=user_id,
resource_type='calendar',
@ -4521,7 +4523,7 @@ async def delete_calendar_event(
if not cal:
return JSONCodec.dumps({'error': 'Access denied'})
if cal.user_id != user_id and __user__.get('role') != 'admin':
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
if not await AccessGrants.has_access(
user_id=user_id,
resource_type='calendar',

View file

@ -314,7 +314,7 @@ async def _get_accessible_kb_ids(
user_id = user.get('id')
user_role = user.get('role', 'user')
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
async def _has_access(kb):
return (

View file

@ -32,6 +32,21 @@ def fill_missing_permissions(permissions: dict[str, Any], default_permissions: d
return permissions
def combine_permissions(permissions: dict[str, Any], group_permissions: dict[str, Any]) -> dict[str, Any]:
"""Combine permissions from multiple groups by taking the most permissive value."""
for key, value in group_permissions.items():
if isinstance(value, dict):
if key not in permissions:
permissions[key] = {}
permissions[key] = combine_permissions(permissions[key], value)
else:
if key not in permissions:
permissions[key] = value
else:
permissions[key] = permissions[key] or value # Use the most permissive value (True > False)
return permissions
async def get_permissions(
user_id: str,
default_permissions: dict[str, Any],
@ -43,21 +58,7 @@ async def get_permissions(
Permissions are nested in a dict with the permission key as the key and a boolean as the value.
"""
def combine_permissions(permissions: dict[str, Any], group_permissions: dict[str, Any]) -> dict[str, Any]:
"""Combine permissions from multiple groups by taking the most permissive value."""
for key, value in group_permissions.items():
if isinstance(value, dict):
if key not in permissions:
permissions[key] = {}
permissions[key] = combine_permissions(permissions[key], value)
else:
if key not in permissions:
permissions[key] = value
else:
permissions[key] = permissions[key] or value # Use the most permissive value (True > False)
return permissions
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
# Deep copy default permissions to avoid modifying the original dict
permissions = JSONCodec.loads(JSONCodec.dumps(default_permissions))
@ -97,7 +98,7 @@ async def has_permission(
permission_hierarchy = permission_key.split('.')
# Retrieve user group permissions
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
for group in user_groups:
if get_permission(group.permissions or {}, permission_hierarchy):
@ -130,7 +131,7 @@ async def has_access(
return False
if user_group_ids is None:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
user_group_ids = {group.id for group in user_groups}
for grant in access_grants:
@ -174,7 +175,7 @@ async def has_connection_access(
return user.role == 'admin'
if user_group_ids is None:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
return await has_access(user.id, 'read', access_grants, user_group_ids)
@ -386,7 +387,9 @@ async def check_model_access(
if user.role != 'admin':
from open_webui.models.access_grants import AccessGrants
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)
}
if not (
user.id == model_info.user_id
or await AccessGrants.has_access(

View file

@ -48,7 +48,9 @@ async def has_access_to_file(
# the user controls would gain write/delete on it (CWE-863). Read access is unaffected.
knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db)
if user_group_ids is None:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
}
for knowledge_base in knowledge_bases:
if (
knowledge_base.user_id == user.id
@ -124,7 +126,9 @@ async def get_accessible_folder_files(
return entries
if user_group_ids is None:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
}
accessible: list[dict] = []
for entry in entries:

View file

@ -101,7 +101,7 @@ async def get_user_groups_for_custom_headers(
return None
try:
return await Groups.get_groups_by_member_id(user.id)
return await Groups.get_groups_by_member_id(user.id, include_inherited=True)
except Exception:
log.exception('Failed to resolve user groups for custom headers')
return None

View file

@ -507,7 +507,9 @@ async def check_model_access(user, model, model_info=None, db=None):
# base-model hop; skipped when no check below needs it.
user_group_ids = None
if user.id != model_info.user_id or model_info.base_model_id:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
}
if not (
user.id == model_info.user_id
@ -547,7 +549,9 @@ async def get_filtered_models(models, user, db=None):
if info:
model_infos[model['id']] = info
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
}
# Batch-fetch accessible resource IDs in a single query instead of N has_access calls
accessible_model_ids = await AccessGrants.get_accessible_resource_ids(

View file

@ -64,7 +64,7 @@ async def prompt_template(template: str, user: Optional[Any] = None) -> str:
try:
from open_webui.models.groups import Groups
user_groups = await Groups.get_groups_by_member_id(user_id)
user_groups = await Groups.get_groups_by_member_id(user_id, include_inherited=True)
groups = ', '.join(g.name for g in user_groups)
except Exception:
pass

View file

@ -153,7 +153,9 @@ async def get_terminal_json(request, user, metadata: dict, path: str, extra_para
or (config.get('context_id') in {'chat_id', 'automation_id'} and not context_id)
):
return None
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_model.id)}
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(user_model.id, include_inherited=True)
}
if not await has_connection_access(user_model, connection, user_group_ids):
return None

View file

@ -352,7 +352,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
tools_dict = {}
# Get user's group memberships for access control checks
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
# Batch-fetch all DB tools in one query instead of one per tool_id
local_tool_ids = [tool_id for tool_id in tool_ids if not tool_id.startswith('server:')]
@ -1471,7 +1471,7 @@ async def get_terminal_tools(
if not connection.get('enabled', True):
raise RuntimeError(f"Terminal server '{terminal_id}' is disabled")
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
if not await has_connection_access(user, connection, user_group_ids):
raise RuntimeError(f'Access denied to terminal {terminal_id}')

View file

@ -295,3 +295,18 @@ export const getGroupPreview = async (token: string, id: string) => {
return res;
};
export const getGroupMembers = async (
token: string,
id: string,
membership: 'direct' | 'inherited' | 'effective' = 'effective',
query = '',
page = 1
) => {
const params = new URLSearchParams({ membership, query, page: String(page) });
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/members?${params}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) throw (await res.json()).detail;
return res.json();
};

View file

@ -1,3 +1,7 @@
<script context="module">
/** @typedef {{ id: string, name: string, parent_group_id?: string | null, path: string, ancestor_ids: string[] }} GroupListItem */
</script>
<script>
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
@ -11,8 +15,9 @@
import XMark from '$lib/components/icons/XMark.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Check from '$lib/components/icons/Check.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import Select from '$lib/components/common/Select.svelte';
import { createNewGroup, getGroups } from '$lib/apis/groups';
import { createNewGroup, getGroups, updateGroupById } from '$lib/apis/groups';
import { getUserDefaultPermissions, updateUserDefaultPermissions } from '$lib/apis/users';
const i18n = getContext('i18n');
@ -23,33 +28,95 @@
let groups = [];
let query = '';
let sortBy = 'members';
let sortBy = 'name';
let collapsed = new Set();
/** @type {any} */
let draggedGroup = null;
let dropTarget = '';
let moving = false;
const sortItems = [
{ value: 'members', label: $i18n.t('Members') },
{ value: 'name', label: $i18n.t('Name') }
];
$: filteredGroups = groups
.filter((group) => {
if (query === '') {
return true;
} else {
let name = group.name.toLowerCase();
const q = query.toLowerCase();
return name.includes(q);
/** @type {any[]} */
let filteredGroups = [];
/** @type {Map<string | null, any[]>} */
let children = new Map();
$: {
children = new Map();
for (const group of groups) {
const parent = group.parent_group_id ?? null;
children.set(parent, [...(children.get(parent) ?? []), group]);
}
for (const siblings of children.values()) {
siblings.sort((a, b) =>
sortBy === 'members'
? (b.member_count ?? 0) - (a.member_count ?? 0) || a.name.localeCompare(b.name)
: a.name.localeCompare(b.name)
);
}
const matching = new Set();
for (const group of groups) {
if (group.path.toLowerCase().includes(query.toLowerCase())) {
matching.add(group.id);
for (const id of group.ancestor_ids) matching.add(id);
}
})
.sort((a, b) => {
if (sortBy === 'name') {
return a.name.localeCompare(b.name);
}
const visible = [];
const pending = [...(children.get(null) ?? [])].reverse();
while (pending.length) {
const group = pending.pop();
if (!matching.has(group.id)) continue;
visible.push(group);
if (query || !collapsed.has(group.id)) {
pending.push(...[...(children.get(group.id) ?? [])].reverse());
}
}
filteredGroups = visible;
}
return (b.member_count ?? 0) - (a.member_count ?? 0) || a.name.localeCompare(b.name);
});
/** @param {string | null} parentId */
const canDrop = (parentId) => {
const parent = groups.find((group) => group.id === parentId);
return (
draggedGroup &&
!moving &&
(draggedGroup.parent_group_id ?? null) !== parentId &&
draggedGroup.id !== parentId &&
!parent?.ancestor_ids.includes(draggedGroup.id)
);
};
/** @param {string | null} parentId */
const moveGroup = async (parentId) => {
if (!canDrop(parentId)) return;
const group = draggedGroup;
draggedGroup = null;
dropTarget = '';
moving = true;
try {
await updateGroupById(localStorage.token, group.id, {
name: group.name,
description: group.description,
parent_group_id: parentId
});
collapsed.delete(parentId);
collapsed = new Set(collapsed);
await setGroups();
toast.success($i18n.t('Group moved successfully'));
} catch (error) {
toast.error(String(error));
} finally {
moving = false;
}
};
$: if (loaded) {
adminGroupCount.set(filteredGroups.length);
adminGroupCount.set(
groups.filter((group) => group.path.toLowerCase().includes(query.toLowerCase())).length
);
}
/** @type {any} */
@ -59,12 +126,31 @@
let showDefaultPermissionsModal = false;
const setGroups = async () => {
groups = await getGroups(localStorage.token);
/** @type {any[]} */
const result = await getGroups(localStorage.token);
const byId = new Map(result.map((group) => [group.id, group]));
groups = result.map((group) => {
const ancestors = [];
const seen = new Set([group.id]);
let parent = byId.get(group.parent_group_id);
while (parent && !seen.has(parent.id)) {
seen.add(parent.id);
ancestors.unshift(parent);
parent = byId.get(parent.parent_group_id);
}
return {
...group,
path: [...ancestors, group].map((item) => item.name).join(' / '),
ancestor_ids: ancestors.map((item) => item.id)
};
});
};
/** @param {any} updatedGroup */
const updateGroup = (updatedGroup) => {
groups = groups.map((group) => (group.id === updatedGroup.id ? updatedGroup : group));
groups = groups.map((group) =>
group.id === updatedGroup.id ? { ...group, ...updatedGroup } : group
);
};
/** @param {any} group */
@ -78,6 +164,7 @@
toast.success($i18n.t('Group created successfully'));
await setGroups();
}
return !!res;
};
/** @param {any} group */
@ -95,6 +182,7 @@
toast.success($i18n.t('Default permissions updated successfully'));
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
}
return !!res;
};
onMount(async () => {
@ -116,6 +204,7 @@
edit={false}
tabs={['general', 'permissions']}
permissions={defaultPermissions}
{groups}
onSubmit={addGroupHandler}
/>
{/if}
@ -185,13 +274,105 @@
</div>
{#if filteredGroups.length !== 0}
<div class="mt-1 grid grid-cols-1">
{#each filteredGroups as group, idx (group.id)}
<GroupItem {group} {setGroups} {updateGroup} {defaultPermissions} />
{#if idx < filteredGroups.length - 1}
<hr class="border-gray-50 dark:border-gray-850/40" />
{/if}
<div class="mt-2" aria-label={$i18n.t('Group hierarchy')}>
{#each filteredGroups as group (group.id)}
<div
role="group"
aria-label={group.name}
class="relative flex items-center rounded-xl transition {dropTarget === group.id
? 'bg-gray-100/40 dark:bg-gray-800/30'
: 'hover:bg-gray-50/60 dark:hover:bg-gray-900'} {draggedGroup?.id === group.id
? 'opacity-40'
: ''}"
style:padding-left={`${Math.min(group.ancestor_ids.length, 8) * 20}px`}
on:dragover={(event) => {
if (canDrop(group.id)) {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
dropTarget = group.id;
}
}}
on:dragleave={(event) => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
)
dropTarget = '';
}}
on:drop|preventDefault={() => moveGroup(group.id)}
>
{#each group.ancestor_ids.slice(0, 8) as ancestorId, depth}
<span
aria-hidden="true"
class="pointer-events-none absolute top-0 bottom-0 border-l border-gray-100 dark:border-gray-900"
style:left={`${depth * 20 + 11}px`}
></span>
{/each}
{#if children.has(group.id)}
<button
type="button"
class="z-10 flex size-6 shrink-0 items-center justify-center rounded-md text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
aria-label={$i18n.t(
collapsed.has(group.id) ? 'Expand {{name}}' : 'Collapse {{name}}',
{ name: group.name }
)}
aria-expanded={!!query || !collapsed.has(group.id)}
on:click={() => {
collapsed.has(group.id) ? collapsed.delete(group.id) : collapsed.add(group.id);
collapsed = new Set(collapsed);
}}
>
<div
class="transition-transform"
class:rotate-90={!!query || !collapsed.has(group.id)}
>
<ChevronRight className="size-3.5" />
</div>
</button>
{:else}<span class="w-6 shrink-0"></span>{/if}
<div class="min-w-0 flex-1">
<GroupItem
{group}
{groups}
{setGroups}
{updateGroup}
{defaultPermissions}
draggable={!moving}
onDragStart={(event) => {
if (!event.dataTransfer) return;
draggedGroup = group;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', group.id);
}}
onDragEnd={() => {
draggedGroup = null;
dropTarget = '';
}}
/>
</div>
</div>
{/each}
{#if draggedGroup}
<div
role="region"
aria-label={$i18n.t('Move to top level')}
class="mt-1 rounded-lg px-3 py-2 text-xs transition {dropTarget === 'root'
? 'bg-gray-100/40 text-gray-500 dark:bg-gray-800/30 dark:text-gray-400'
: 'text-gray-400 dark:text-gray-500'}"
on:dragover={(event) => {
if (canDrop(null)) {
event.preventDefault();
dropTarget = 'root';
}
}}
on:dragleave={() => {
dropTarget = '';
}}
on:drop|preventDefault={() => moveGroup(null)}
>
{$i18n.t('Move to top level')}
</div>
{/if}
</div>
{:else}
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">

View file

@ -1,13 +1,15 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const i18n = getContext<import('svelte/store').Writable<import('i18next').i18n>>('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import General from './General.svelte';
import Permissions from './Permissions.svelte';
import Users from './Users.svelte';
import InheritedMembers from './InheritedMembers.svelte';
import { getGroupPreview } from '$lib/apis/groups';
import GroupPreviewPanel from './GroupPreviewPanel.svelte';
import { DEFAULT_PERMISSIONS } from '$lib/constants/permissions';
import { getUserDefaultPermissions, getUserDefaultPermissionsDefaults } from '$lib/apis/users';
@ -24,8 +26,38 @@
export let show = false;
export let edit = false;
export let group = null;
export let defaultPermissions = {};
export let group: any = null;
export let groups: import('../Groups.svelte').GroupListItem[] = [];
let parent_group_id: string | null = null;
let membershipTab = 'direct';
let inheritedPermissions: Record<string, any> = {};
let loadedParent: string | null | undefined = undefined;
let inheritanceRequest = 0;
async function loadInheritance(parentId: string | null) {
loadedParent = parentId;
const request = ++inheritanceRequest;
inheritedPermissions = defaultPermissions;
if (!parentId) return;
try {
const preview = await getGroupPreview(localStorage.token, parentId);
if (request === inheritanceRequest) inheritedPermissions = preview.effective_permissions;
} catch (e) {
if (request === inheritanceRequest && show) toast.error(String(e));
}
}
$: deleteMessage = group?.parent_group_id
? $i18n.t(
'Delete {{name}}? Its child groups will move to {{parent}}. Permissions and direct memberships belonging to this group will be removed.',
{
name: group?.name,
parent: groups.find((item) => item.id === group.parent_group_id)?.path
}
)
: $i18n.t(
'Delete {{name}}? Its child groups will become top-level groups. Permissions and direct memberships belonging to this group will be removed.',
{ name: group?.name }
);
export let defaultPermissions: Record<string, any> = {};
export let custom = true;
@ -40,7 +72,7 @@
export let name = '';
export let description = '';
export let data = {};
export let data: Record<string, any> = {};
export let permissions = DEFAULT_PERMISSIONS;
@ -51,13 +83,17 @@
name,
description,
data,
permissions
permissions,
...(custom ? { parent_group_id } : {})
};
await onSubmit(group);
loading = false;
show = false;
try {
if (await onSubmit(group)) show = false;
} catch (error) {
toast.error(String(error));
} finally {
loading = false;
}
};
const resetToDefaultsHandler = async () => {
@ -86,8 +122,9 @@
const init = () => {
if (group) {
name = group.name;
parent_group_id = group.parent_group_id ?? null;
description = group.description;
const loadedPermissions = group?.permissions ?? {};
const loadedPermissions: Record<string, any> = group?.permissions ?? {};
// Create fresh object from defaults, then overlay loaded values
permissions = {
workspace: { ...DEFAULT_PERMISSIONS.workspace, ...loadedPermissions.workspace },
@ -106,6 +143,13 @@
$: if (show) {
init();
}
$: if (!show) {
loadedParent = undefined;
inheritanceRequest++;
membershipTab = 'direct';
}
$: if (show && parent_group_id !== loadedParent) loadInheritance(parent_group_id);
onMount(() => {
selectedTab = tabs[0];
@ -115,9 +159,14 @@
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
onDelete();
show = false;
title={$i18n.t('Delete group')}
message={deleteMessage}
on:confirm={async () => {
try {
if (await onDelete()) show = false;
} catch (error) {
toast.error(String(error));
}
}}
/>
@ -172,7 +221,7 @@
>
{#if tabs.includes('general')}
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 max-w-fit w-fit rounded-lg shrink-0 whitespace-nowrap flex text-right transition {selectedTab ===
'general'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@ -201,7 +250,7 @@
{#if tabs.includes('permissions')}
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 max-w-fit w-fit rounded-lg shrink-0 whitespace-nowrap flex text-right transition {selectedTab ===
'permissions'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@ -219,7 +268,7 @@
{#if tabs.includes('users')}
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 max-w-fit w-fit rounded-lg shrink-0 whitespace-nowrap flex text-right transition {selectedTab ===
'users'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@ -237,7 +286,7 @@
{#if tabs.includes('preview')}
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
class="px-0.5 py-1 max-w-fit w-fit rounded-lg shrink-0 whitespace-nowrap flex text-right transition {selectedTab ===
'preview'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
@ -270,6 +319,9 @@
<div class="w-full h-full overflow-y-auto scrollbar-hidden">
{#if selectedTab == 'general'}
<General
{groups}
groupId={group?.id}
bind:parent_group_id
bind:name
bind:description
bind:data
@ -279,9 +331,37 @@
}}
/>
{:else if selectedTab == 'permissions'}
<Permissions bind:permissions {defaultPermissions} />
{#if custom && parent_group_id}
<p class="text-xs text-gray-500 mb-3">
{$i18n.t(
'Parent permissions are inherited. Disabling a local permission does not revoke inherited access.'
)}
</p>
{/if}
<Permissions
bind:permissions
defaultPermissions={custom ? inheritedPermissions : defaultPermissions}
/>
{:else if selectedTab == 'users'}
<Users bind:userCount groupId={group?.id} {onMemberChange} />
<div class="flex gap-3 mb-3 text-sm">
<button
type="button"
class:underline={membershipTab === 'direct'}
on:click={() => (membershipTab = 'direct')}
>{$i18n.t('Direct members')}</button
>
<button
type="button"
class:underline={membershipTab === 'inherited'}
on:click={() => (membershipTab = 'inherited')}
>{$i18n.t('Inherited members')}</button
>
</div>
{#if membershipTab === 'direct'}
<Users bind:userCount groupId={group?.id} {onMemberChange} />
{:else}
<InheritedMembers groupId={group?.id} {groups} />
{/if}
{:else if selectedTab == 'preview'}
<GroupPreviewPanel groupId={group?.id} />
{/if}

View file

@ -1,16 +1,36 @@
<script lang="ts">
import { getContext } from 'svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Check from '$lib/components/icons/Check.svelte';
import Search from '$lib/components/icons/Search.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
const i18n = getContext<import('svelte/store').Writable<import('i18next').i18n>>('i18n');
export let name = '';
export let color = '';
export let description = '';
export let data = {};
export let data: Record<string, any> = {};
export let edit = false;
export let groupId: string | undefined = undefined;
export let groups: import('../Groups.svelte').GroupListItem[] = [];
export let parent_group_id: string | null = null;
let parentSearch = '';
let showParents = false;
$: selectedParent = groups.find((group) => group.id === parent_group_id);
$: candidates = groups
.filter(
(group) =>
group.id !== groupId &&
(!groupId || !group.ancestor_ids.includes(groupId)) &&
(group.id === parent_group_id ||
group.path.toLowerCase().includes(parentSearch.toLowerCase()))
)
.sort((a, b) => a.path.localeCompare(b.path));
export let onDelete: Function = () => {};
</script>
@ -31,6 +51,87 @@
</div>
</div>
<div class="my-3 space-y-1">
<div class="flex items-center justify-between gap-3">
<Tooltip content={$i18n.t('Members of this group inherit access from its parent groups.')}>
<label for="group-parent" class="text-xs shrink-0 text-gray-500"
>{$i18n.t('Parent group')}</label
>
</Tooltip>
<Dropdown
align="end"
bind:show={showParents}
contentClass="w-80 max-w-[calc(100vw-2rem)]"
maxHeight="20rem"
onOpenChange={(open) => {
if (!open) parentSearch = '';
}}
>
<button
id="group-parent"
type="button"
aria-haspopup="menu"
aria-expanded={showParents}
class="flex max-w-64 items-center gap-1.5 rounded-lg px-1.5 py-1 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-900"
>
<span class="min-w-0 flex-1 truncate">{selectedParent?.name ?? $i18n.t('No parent')}</span>
<ChevronDown className="size-3.5 shrink-0 text-gray-400" />
</button>
<div slot="content">
<DropdownMenu className="w-full">
<div class="flex items-center gap-2 px-2 py-2">
<Search className="size-3.5 shrink-0 text-gray-400" />
<input
aria-label={$i18n.t('Search parent groups')}
placeholder={$i18n.t('Search parent groups')}
class="min-w-0 w-full bg-transparent text-sm outline-hidden"
bind:value={parentSearch}
/>
</div>
<button
type="button"
role="menuitemradio"
aria-checked={!parent_group_id}
class="my-1 flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-800"
on:click={() => {
parent_group_id = null;
showParents = false;
}}
>
<span class="flex-1">{$i18n.t('No parent')}</span>
{#if !parent_group_id}<Check className="size-4" />{/if}
</button>
{#each candidates as candidate (candidate.id)}
<button
type="button"
role="menuitemradio"
aria-checked={candidate.id === parent_group_id}
class="relative flex w-full items-center gap-2 rounded-lg py-2 pr-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-800"
title={candidate.path}
on:click={() => {
parent_group_id = candidate.id;
showParents = false;
}}
>
{#if candidate.ancestor_ids.length}<span
aria-hidden="true"
class="absolute top-0 h-1/2 w-2 border-b border-l border-gray-200 dark:border-gray-700"
style:left={`${Math.min(candidate.ancestor_ids.length, 6) * 16 - 4}px`}
></span>{/if}
<span
class="flex-1 truncate"
style:margin-left={`${Math.min(candidate.ancestor_ids.length, 6) * 16}px`}
>{candidate.name}</span
>
{#if candidate.id === parent_group_id}<Check className="size-4 shrink-0" />{/if}
</button>
{:else}<p class="px-2 py-3 text-xs text-gray-500">{$i18n.t('No groups found')}</p>{/each}
</DropdownMenu>
</div>
</Dropdown>
</div>
</div>
<!-- <div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Color')}</div>
@ -80,7 +181,7 @@
class="text-sm bg-transparent outline-hidden rounded-lg pl-2 pr-5"
value={data?.config?.share ?? 'members'}
on:change={(e) => {
const value = e.target.value;
const value = e.currentTarget.value;
let shareValue;
if (value === 'false') {
shareValue = false;

View file

@ -7,11 +7,18 @@
import { deleteGroupById, updateGroupById } from '$lib/apis/groups';
/** @type {import('../Groups.svelte').GroupListItem[]} */
export let groups = [];
export let draggable = false;
/** @type {(event: DragEvent) => void} */
export let onDragStart = () => {};
export let onDragEnd = () => {};
import EditGroupModal from './EditGroupModal.svelte';
export let group = {
id: '',
name: 'Admins',
path: 'Admins',
description: '',
permissions: {},
user_ids: [1, 2, 3],
@ -26,6 +33,7 @@
let showEdit = false;
$: hasCustomPermissions = Object.keys(group?.permissions ?? {}).length > 0;
/** @param {any} _group */
const updateHandler = async (_group) => {
const res = await updateGroupById(localStorage.token, group.id, _group).catch((error) => {
toast.error(`${error}`);
@ -34,8 +42,9 @@
if (res) {
toast.success($i18n.t('Group updated successfully'));
setGroups();
await setGroups();
}
return !!res;
};
const deleteHandler = async () => {
@ -46,8 +55,9 @@
if (res) {
toast.success($i18n.t('Group deleted successfully'));
setGroups();
await setGroups();
}
return !!res;
};
onMount(() => {
@ -62,6 +72,7 @@
bind:show={showEdit}
edit
{group}
{groups}
{defaultPermissions}
tabs={['general', 'permissions', 'users', 'preview']}
onSubmit={updateHandler}
@ -70,15 +81,19 @@
/>
<button
class="group flex cursor-pointer text-left w-full px-2.5 py-2"
class="group flex cursor-pointer text-left w-full px-1.5 py-1.5"
{draggable}
on:dragstart={onDragStart}
on:dragend={onDragEnd}
title={group.path}
on:click={() => {
showEdit = true;
}}
>
<div class="w-full">
<div class="min-w-0 w-full">
<div class="flex items-center gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-0.5 pl-1">
<div class="flex min-w-0 items-center gap-2">
<div class="flex min-w-0 flex-1 items-center gap-3 pl-1">
<div class="flex min-w-0 items-center gap-2 sm:shrink-0 sm:max-w-[55%]">
<div
class="text-sm font-normal line-clamp-1 text-gray-900 group-hover:underline dark:text-gray-100"
>
@ -88,11 +103,11 @@
<div
class="shrink-0 rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[0.6875rem] font-normal leading-none text-gray-600 dark:text-gray-300"
>
{$i18n.t('{{COUNT}} members', { COUNT: group?.member_count ?? 0 })}
{$i18n.t('{{COUNT}} direct members', { COUNT: group?.member_count ?? 0 })}
</div>
</div>
<div class="flex min-w-0 items-center gap-1.5 text-xs text-gray-500">
<div class="hidden min-w-0 items-center gap-1.5 text-xs text-gray-500 sm:flex">
<div class="line-clamp-1 min-w-0">
{#if group?.description}
{group.description}

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getGroupMembers } from '$lib/apis/groups';
const i18n = getContext<any>('i18n');
export let groupId: string;
export let groups: import('../Groups.svelte').GroupListItem[] = [];
let query = '';
let page = 1;
$: members = getGroupMembers(localStorage.token, groupId, 'inherited', query, page);
</script>
<div class="space-y-3 text-sm">
<p class="text-xs text-gray-500">
{$i18n.t('Inherited members are managed in their directly assigned groups.')}
</p>
<input
class="w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-transparent px-2 py-1"
aria-label={$i18n.t('Search inherited members')}
placeholder={$i18n.t('Search inherited members')}
bind:value={query}
on:input={() => {
page = 1;
}}
/>
{#await members}
<p>{$i18n.t('Loading...')}</p>
{:then result}
<p class="text-xs text-gray-500">
{$i18n.t(
'Direct: {{direct}} · Inherited: {{inherited}} · Total: {{effective}}',
result.counts
)}
</p>
{#each result.items as member (member.id)}
<div class="border-b border-gray-100 dark:border-gray-800 py-2">
<div>{member.name} <span class="text-gray-500">{member.email}</span></div>
<div class="text-xs text-gray-500">
{$i18n.t('Inherited via')}
{#each member.via_group_ids as id, index}
{index ? ', ' : ''}<a class="underline" href={`/admin/users/groups?id=${id}`}
>{groups.find((group) => group.id === id)?.path || id}</a
>
{/each}
</div>
</div>
{:else}<p class="text-gray-500">{$i18n.t('No inherited members')}</p>{/each}
<div class="flex items-center justify-between">
<button type="button" disabled={page <= 1} class="disabled:opacity-40" on:click={() => page--}
>{$i18n.t('Previous')}</button
>
<span>{page} / {Math.max(1, Math.ceil(result.total / 30))}</span>
<button
type="button"
disabled={page * 30 >= result.total}
class="disabled:opacity-40"
on:click={() => page++}>{$i18n.t('Next')}</button
>
</div>
{:catch error}
<p role="alert" class="text-red-500">{String(error)}</p>
{/await}
</div>

View file

@ -714,6 +714,11 @@
}
const socketInstance = $socket;
const scheduleChannelRefresh = () => {
socketInstance?.off('connect', initChannels);
socketInstance?.once('connect', initChannels);
};
socketInstance?.on('access:updated', scheduleChannelRefresh);
socketInstance?.on('events', chatActiveEventHandler);
socketInstance?.on('connect', refreshChatRows);
@ -753,6 +758,8 @@
dropZone.removeEventListener('dragleave', onDragLeave);
}
socketInstance?.off('access:updated', scheduleChannelRefresh);
socketInstance?.off('connect', initChannels);
socketInstance?.off('events', chatActiveEventHandler);
socketInstance?.off('connect', refreshChatRows);

View file

@ -3538,5 +3538,26 @@
"YouTube": "",
"YouTube video player": "",
"Zoom in": "",
"Zoom out": ""
"Zoom out": "",
"Parent group": "Parent group",
"Search parent groups": "Search parent groups",
"No parent": "No parent",
"Members of this group inherit access from its parent groups.": "Members of this group inherit access from its parent groups.",
"{{COUNT}} direct members": "{{COUNT}} direct members",
"Inherited members are managed in their directly assigned groups.": "Inherited members are managed in their directly assigned groups.",
"Direct: {{direct}} · Inherited: {{inherited}} · Total: {{effective}}": "Direct: {{direct}} · Inherited: {{inherited}} · Total: {{effective}}",
"Search inherited members": "Search inherited members",
"Inherited via": "Inherited via",
"No inherited members": "No inherited members",
"Delete {{name}}? Its child groups will move to {{parent}}. Permissions and direct memberships belonging to this group will be removed.": "Delete {{name}}? Its child groups will move to {{parent}}. Permissions and direct memberships belonging to this group will be removed.",
"Delete {{name}}? Its child groups will become top-level groups. Permissions and direct memberships belonging to this group will be removed.": "Delete {{name}}? Its child groups will become top-level groups. Permissions and direct memberships belonging to this group will be removed.",
"Delete group": "Delete group",
"Parent permissions are inherited. Disabling a local permission does not revoke inherited access.": "Parent permissions are inherited. Disabling a local permission does not revoke inherited access.",
"Direct members": "Direct members",
"Inherited members": "Inherited members",
"Group hierarchy": "Group hierarchy",
"Move to top level": "Move to top level",
"Expand {{name}}": "Expand {{name}}",
"Collapse {{name}}": "Collapse {{name}}",
"Group moved successfully": "Group moved successfully"
}

View file

@ -8,6 +8,8 @@
import { getModels, getToolServersData, getVersionUpdates } from '$lib/apis';
import { getTools } from '$lib/apis/tools';
import { getSkills } from '$lib/apis/skills';
import { getSessionUser } from '$lib/apis/auths';
import { getBanners } from '$lib/apis/configs';
import { getTerminalServers } from '$lib/apis/terminal';
import { getUserSettings } from '$lib/apis/users';
@ -23,6 +25,8 @@
models,
knowledge,
tools,
skills,
socket,
functions,
tags,
banners,
@ -168,6 +172,37 @@
tools.set(toolsData);
};
const refreshAccess = async () => {
if (!localStorage.token) return;
try {
user.set(await getSessionUser(localStorage.token));
const results = await Promise.allSettled([
setModels(),
setTools(),
setToolServers(),
getSkills(localStorage.token).then((value) => skills.set(value))
]);
for (const result of results) {
if (result.status === 'rejected') console.error('Unable to refresh access', result.reason);
}
} catch (error) {
console.error('Unable to refresh permissions', error);
}
};
onMount(() => {
const socketInstance = $socket;
const scheduleAccessRefresh = () => {
socketInstance?.off('connect', refreshAccess);
socketInstance?.once('connect', refreshAccess);
};
socketInstance?.on('access:updated', scheduleAccessRefresh);
return () => {
socketInstance?.off('access:updated', scheduleAccessRefresh);
socketInstance?.off('connect', refreshAccess);
};
});
const openSettingsFromUrl = async () => {
const requestedSettings = $page.url.searchParams.get('settings');
if (!requestedSettings) {

View file

@ -12,6 +12,7 @@
import {
config,
user,
models,
settings,
theme,
WEBUI_NAME,