diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py index ff3a1997f7..3a06cb287c 100755 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES from open_webui.internal.db import get_async_session +from open_webui.models.access_grants import AccessGrants from open_webui.models.groups import ( GroupForm, GroupInfoResponse, @@ -15,6 +16,9 @@ from open_webui.models.groups import ( GroupUpdateForm, UserIdsForm, ) +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.utils.auth import get_admin_user, get_verified_user from sqlalchemy.ext.asyncio import AsyncSession @@ -273,3 +277,88 @@ async def delete_group_by_id(id: str, user=Depends(get_admin_user), db: AsyncSes status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), ) + + +############################ +# PreviewGroupAccess +############################ + + +@router.get('/id/{id}/preview') +async def preview_group_access( + id: str, + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + """Show what resources a group can access (preview audit).""" + group = await Groups.get_group_by_id(id, db=db) + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + group_ids = {group.id} + + # Batch-check accessible resources using existing AccessGrants + all_models = await Models.get_all_models(db=db) + accessible_model_ids = await AccessGrants.get_accessible_resource_ids( + user_id='', + resource_type='model', + resource_ids=[m.id for m in all_models], + permission='read', + user_group_ids=group_ids, + db=db, + ) + + all_knowledge = await Knowledges.get_knowledge_bases(db=db) + accessible_knowledge_ids = await AccessGrants.get_accessible_resource_ids( + user_id='', + resource_type='knowledge', + resource_ids=[k.id for k in all_knowledge], + permission='read', + user_group_ids=group_ids, + db=db, + ) + + all_tools = await Tools.get_tools(defer_content=True, db=db) + accessible_tool_ids = await AccessGrants.get_accessible_resource_ids( + user_id='', + resource_type='tool', + resource_ids=[t.id for t in all_tools], + permission='read', + user_group_ids=group_ids, + db=db, + ) + + active_models = [m for m in all_models if m.is_active] + + return { + 'group': {'id': group.id, 'name': group.name}, + 'models': { + 'items': [ + {'id': m.id, 'name': m.name} + for m in active_models + if m.id in accessible_model_ids + ], + 'total': len(active_models), + }, + 'knowledge': { + 'items': [ + {'id': k.id, 'name': k.name} + for k in all_knowledge + if k.id in accessible_knowledge_ids + ], + 'total': len(all_knowledge), + }, + 'tools': { + 'items': [ + {'id': t.id, 'name': t.name} + for t in all_tools + if t.id in accessible_tool_ids + ], + 'total': len(all_tools), + }, + 'permissions': group.permissions or {}, + } + diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index c4a54744c5..3c215c8f16 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 import io import logging +import time from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -25,6 +26,10 @@ from open_webui.models.users import ( UserStatus, UserUpdateForm, ) +from open_webui.models.access_grants import AccessGrants +from open_webui.models.knowledge import Knowledges +from open_webui.models.models import Models +from open_webui.models.tools import Tools from open_webui.socket.main import disconnect_user_sessions from open_webui.utils.access_control import get_permissions, has_permission from open_webui.utils.auth import ( @@ -677,3 +682,88 @@ async def get_user_groups_by_id( user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session) ): return await Groups.get_groups_by_member_id(user_id, db=db) + + +############################ +# GetUserPreview +############################ + + +@router.get('/{user_id}/preview') +async def get_user_preview( + user_id: str, + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + """Show what resources a specific user can access across all their groups.""" + target_user = await Users.get_user_by_id(user_id, db=db) + if not target_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + + # Get all group IDs this user belongs to + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = {g.id for g in user_groups} + + all_models = await Models.get_all_models(db=db) + accessible_model_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user_id, + resource_type='model', + resource_ids=[m.id for m in all_models], + permission='read', + user_group_ids=user_group_ids, + db=db, + ) + + all_knowledge = await Knowledges.get_knowledge_bases(db=db) + accessible_knowledge_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user_id, + resource_type='knowledge', + resource_ids=[k.id for k in all_knowledge], + permission='read', + user_group_ids=user_group_ids, + db=db, + ) + + all_tools = await Tools.get_tools(defer_content=True, db=db) + accessible_tool_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user_id, + resource_type='tool', + resource_ids=[t.id for t in all_tools], + permission='read', + user_group_ids=user_group_ids, + db=db, + ) + + active_models = [m for m in all_models if m.is_active] + + return { + 'user': {'id': target_user.id, 'name': target_user.name}, + 'groups': [{'id': g.id, 'name': g.name} for g in user_groups], + 'models': { + 'items': [ + {'id': m.id, 'name': m.name} + for m in active_models + if m.id in accessible_model_ids + ], + 'total': len(active_models), + }, + 'knowledge': { + 'items': [ + {'id': k.id, 'name': k.name} + for k in all_knowledge + if k.id in accessible_knowledge_ids + ], + 'total': len(all_knowledge), + }, + 'tools': { + 'items': [ + {'id': t.id, 'name': t.name} + for t in all_tools + if t.id in accessible_tool_ids + ], + 'total': len(all_tools), + }, + } diff --git a/src/lib/apis/groups/index.ts b/src/lib/apis/groups/index.ts index 6089a6023f..dfc6aa6a71 100644 --- a/src/lib/apis/groups/index.ts +++ b/src/lib/apis/groups/index.ts @@ -267,3 +267,31 @@ export const removeUserFromGroup = async (token: string, id: string, userIds: st return res; }; + +export const getGroupPreview = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/preview`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index 91b63338de..267aa69d22 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -550,3 +550,30 @@ export const getUserGroupsById = async (token: string, userId: string) => { return res; }; + +export const getUserPreview = async (token: string, userId: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}/preview`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/admin/UserPreviewModal.svelte b/src/lib/components/admin/UserPreviewModal.svelte new file mode 100644 index 0000000000..ce2c66bde5 --- /dev/null +++ b/src/lib/components/admin/UserPreviewModal.svelte @@ -0,0 +1,175 @@ + + + +
+
+
+ {$i18n.t('User Preview')} + {#if userName} + {userName} + {/if} +
+ +
+ +
+ {#if loading} +
+ +
+ {:else if error} +
{error}
+ {:else if preview} +
+ {#if preview.groups.length > 0} +
+
{$i18n.t('Groups')}
+
+ {#each preview.groups as group} +
+
{group.name}
+
+ {/each} +
+
+ +
+ {/if} + +
+
{$i18n.t('Models')}
+
+ {#if preview.models.items.length === 0} +
+
+ {$i18n.t('No models accessible')} +
+
+ {:else} + {#each preview.models.items as model} +
+
{model.name}
+
+ {/each} + + {#if preview.models.total > preview.models.items.length} +
+
+ {$i18n.t('{{count}} of {{total}} accessible', { + count: preview.models.items.length, + total: preview.models.total + })} +
+
+ {/if} + {/if} +
+
+ +
+ +
+
{$i18n.t('Knowledge')}
+
+ {#if preview.knowledge.items.length === 0} +
+
+ {$i18n.t('No knowledge bases accessible')} +
+
+ {:else} + {#each preview.knowledge.items as kb} +
+
{kb.name}
+
+ {/each} + + {#if preview.knowledge.total > preview.knowledge.items.length} +
+
+ {$i18n.t('{{count}} of {{total}} accessible', { + count: preview.knowledge.items.length, + total: preview.knowledge.total + })} +
+
+ {/if} + {/if} +
+
+ +
+ +
+
{$i18n.t('Tools')}
+
+ {#if preview.tools.items.length === 0} +
+
+ {$i18n.t('No tools accessible')} +
+
+ {:else} + {#each preview.tools.items as tool} +
+
{tool.name}
+
+ {/each} + + {#if preview.tools.total > preview.tools.items.length} +
+
+ {$i18n.t('{{count}} of {{total}} accessible', { + count: preview.tools.items.length, + total: preview.tools.total + })} +
+
+ {/if} + {/if} +
+
+
+ {/if} +
+
+
diff --git a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte index b3290a11e4..d446f9f62a 100644 --- a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte +++ b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte @@ -8,6 +8,7 @@ import General from './General.svelte'; import Permissions from './Permissions.svelte'; import Users from './Users.svelte'; + import GroupPreviewPanel from './GroupPreviewPanel.svelte'; import { DEFAULT_PERMISSIONS } from '$lib/constants/permissions'; import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte'; import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; @@ -195,6 +196,36 @@
{$i18n.t('Users')}
{/if} + + {#if tabs.includes('preview')} + + {/if}
@@ -213,6 +244,8 @@ {:else if selectedTab == 'users'} + {:else if selectedTab == 'preview'} + {/if}
diff --git a/src/lib/components/admin/Users/Groups/GroupItem.svelte b/src/lib/components/admin/Users/Groups/GroupItem.svelte index 86348f3d75..8425321889 100644 --- a/src/lib/components/admin/Users/Groups/GroupItem.svelte +++ b/src/lib/components/admin/Users/Groups/GroupItem.svelte @@ -57,6 +57,7 @@ edit {group} {defaultPermissions} + tabs={['general', 'permissions', 'users', 'preview']} onSubmit={updateHandler} onDelete={deleteHandler} /> diff --git a/src/lib/components/admin/Users/Groups/GroupPreviewPanel.svelte b/src/lib/components/admin/Users/Groups/GroupPreviewPanel.svelte new file mode 100644 index 0000000000..585d36b8b8 --- /dev/null +++ b/src/lib/components/admin/Users/Groups/GroupPreviewPanel.svelte @@ -0,0 +1,136 @@ + + +
+ {#if loading} +
+ +
+ {:else if error} +
{error}
+ {:else if preview} +
+
{$i18n.t('Models')}
+ +
+ {#if preview.models.items.length === 0} +
+
+ {$i18n.t('No models accessible')} +
+
+ {:else} + {#each preview.models.items as model} +
+
{model.name}
+
+ {/each} + + {#if preview.models.total > preview.models.items.length} +
+
+ {$i18n.t('{{count}} of {{total}} accessible', { + count: preview.models.items.length, + total: preview.models.total + })} +
+
+ {/if} + {/if} +
+
+ +
+ +
+
{$i18n.t('Knowledge')}
+ +
+ {#if preview.knowledge.items.length === 0} +
+
+ {$i18n.t('No knowledge bases accessible')} +
+
+ {:else} + {#each preview.knowledge.items as kb} +
+
{kb.name}
+
+ {/each} + + {#if preview.knowledge.total > preview.knowledge.items.length} +
+
+ {$i18n.t('{{count}} of {{total}} accessible', { + count: preview.knowledge.items.length, + total: preview.knowledge.total + })} +
+
+ {/if} + {/if} +
+
+ +
+ +
+
{$i18n.t('Tools')}
+ +
+ {#if preview.tools.items.length === 0} +
+
+ {$i18n.t('No tools accessible')} +
+
+ {:else} + {#each preview.tools.items as tool} +
+
{tool.name}
+
+ {/each} + + {#if preview.tools.total > preview.tools.items.length} +
+
+ {$i18n.t('{{count}} of {{total}} accessible', { + count: preview.tools.items.length, + total: preview.tools.total + })} +
+
+ {/if} + {/if} +
+
+ {/if} +
diff --git a/src/lib/components/admin/Users/Groups/Users.svelte b/src/lib/components/admin/Users/Groups/Users.svelte index ace5141a7f..4af8fe418f 100644 --- a/src/lib/components/admin/Users/Groups/Users.svelte +++ b/src/lib/components/admin/Users/Groups/Users.svelte @@ -261,6 +261,7 @@ {dayjs(user.last_active_at * 1000).fromNow()} + {/each} diff --git a/src/lib/components/admin/Users/UserList.svelte b/src/lib/components/admin/Users/UserList.svelte index 042f112cf1..0a58618473 100644 --- a/src/lib/components/admin/Users/UserList.svelte +++ b/src/lib/components/admin/Users/UserList.svelte @@ -34,6 +34,7 @@ import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte'; + import UserPreviewModal from '$lib/components/admin/UserPreviewModal.svelte'; const i18n = getContext('i18n'); @@ -54,6 +55,7 @@ let showUserChatsModal = false; let showEditUserModal = false; + let showUserPreviewModal = false; const deleteUserHandler = async (id) => { const res = await deleteUserById(localStorage.token, id).catch((error) => { @@ -425,6 +427,39 @@ {/if} + {#if user.role !== 'admin'} + + + + {/if} +