This commit is contained in:
Timothy Jaeryang Baek
2026-05-14 13:12:59 +09:00
parent 74ae177d79
commit 9c14740ffb
10 changed files with 619 additions and 0 deletions
+89
View File
@@ -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 {},
}
+90
View File
@@ -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),
},
}
+28
View File
@@ -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;
};
+27
View File
@@ -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;
};
@@ -0,0 +1,175 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getUserPreview } from '$lib/apis/users';
import Modal from '$lib/components/common/Modal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
export let show = false;
export let userId: string = '';
export let userName: string = '';
let loading = true;
let preview: any = null;
let error: string = '';
$: if (show && userId) {
loadPreview();
}
const loadPreview = async () => {
loading = true;
error = '';
try {
preview = await getUserPreview(localStorage.token, userId);
} catch (e) {
error = String(e);
} finally {
loading = false;
}
};
</script>
<Modal size="md" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('User Preview')}
{#if userName}
<span class="text-sm font-normal text-gray-500 ml-1">{userName}</span>
{/if}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col w-full px-5 pb-4">
{#if loading}
<div class="flex justify-center items-center py-8">
<Spinner className="size-5" />
</div>
{:else if error}
<div class="text-red-500 text-xs text-center py-4">{error}</div>
{:else if preview}
<div class="space-y-2">
{#if preview.groups.length > 0}
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Groups')}</div>
<div class="flex flex-col w-full">
{#each preview.groups as group}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">{group.name}</div>
</div>
{/each}
</div>
</div>
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
{/if}
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Models')}</div>
<div class="flex flex-col w-full">
{#if preview.models.items.length === 0}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('No models accessible')}
</div>
</div>
{:else}
{#each preview.models.items as model}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">{model.name}</div>
</div>
{/each}
{#if preview.models.total > preview.models.items.length}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('{{count}} of {{total}} accessible', {
count: preview.models.items.length,
total: preview.models.total
})}
</div>
</div>
{/if}
{/if}
</div>
</div>
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Knowledge')}</div>
<div class="flex flex-col w-full">
{#if preview.knowledge.items.length === 0}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('No knowledge bases accessible')}
</div>
</div>
{:else}
{#each preview.knowledge.items as kb}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">{kb.name}</div>
</div>
{/each}
{#if preview.knowledge.total > preview.knowledge.items.length}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('{{count}} of {{total}} accessible', {
count: preview.knowledge.items.length,
total: preview.knowledge.total
})}
</div>
</div>
{/if}
{/if}
</div>
</div>
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Tools')}</div>
<div class="flex flex-col w-full">
{#if preview.tools.items.length === 0}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('No tools accessible')}
</div>
</div>
{:else}
{#each preview.tools.items as tool}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">{tool.name}</div>
</div>
{/each}
{#if preview.tools.total > preview.tools.items.length}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('{{count}} of {{total}} accessible', {
count: preview.tools.items.length,
total: preview.tools.total
})}
</div>
</div>
{/if}
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>
</Modal>
@@ -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 @@
<div class=" self-center">{$i18n.t('Users')}</div>
</button>
{/if}
{#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 ===
'preview'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'preview';
}}
type="button"
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path
fill-rule="evenodd"
d="M1.38 8.28a.87.87 0 0 1 0-.566 7.003 7.003 0 0 1 13.238.006.87.87 0 0 1 0 .566A7.003 7.003 0 0 1 1.379 8.28ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Preview')}</div>
</button>
{/if}
</div>
<div class="flex-1 mt-1 lg:mt-1 lg:h-[30rem] lg:max-h-[30rem] flex flex-col">
@@ -213,6 +244,8 @@
<Permissions bind:permissions {defaultPermissions} />
{:else if selectedTab == 'users'}
<Users bind:userCount groupId={group?.id} />
{:else if selectedTab == 'preview'}
<GroupPreviewPanel groupId={group?.id} />
{/if}
</div>
@@ -57,6 +57,7 @@
edit
{group}
{defaultPermissions}
tabs={['general', 'permissions', 'users', 'preview']}
onSubmit={updateHandler}
onDelete={deleteHandler}
/>
@@ -0,0 +1,136 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getGroupPreview } from '$lib/apis/groups';
import Spinner from '$lib/components/common/Spinner.svelte';
const i18n = getContext('i18n');
export let groupId: string = '';
let loading = true;
let preview: any = null;
let error: string = '';
$: if (groupId) {
loadPreview();
}
const loadPreview = async () => {
loading = true;
error = '';
try {
preview = await getGroupPreview(localStorage.token, groupId);
} catch (e) {
error = String(e);
} finally {
loading = false;
}
};
</script>
<div class="space-y-2">
{#if loading}
<div class="flex justify-center items-center py-8">
<Spinner className="size-5" />
</div>
{:else if error}
<div class="text-red-500 text-xs text-center py-4">{error}</div>
{:else if preview}
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Models')}</div>
<div class="flex flex-col w-full">
{#if preview.models.items.length === 0}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('No models accessible')}
</div>
</div>
{:else}
{#each preview.models.items as model}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">{model.name}</div>
</div>
{/each}
{#if preview.models.total > preview.models.items.length}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('{{count}} of {{total}} accessible', {
count: preview.models.items.length,
total: preview.models.total
})}
</div>
</div>
{/if}
{/if}
</div>
</div>
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Knowledge')}</div>
<div class="flex flex-col w-full">
{#if preview.knowledge.items.length === 0}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('No knowledge bases accessible')}
</div>
</div>
{:else}
{#each preview.knowledge.items as kb}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">{kb.name}</div>
</div>
{/each}
{#if preview.knowledge.total > preview.knowledge.items.length}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('{{count}} of {{total}} accessible', {
count: preview.knowledge.items.length,
total: preview.knowledge.total
})}
</div>
</div>
{/if}
{/if}
</div>
</div>
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Tools')}</div>
<div class="flex flex-col w-full">
{#if preview.tools.items.length === 0}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('No tools accessible')}
</div>
</div>
{:else}
{#each preview.tools.items as tool}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">{tool.name}</div>
</div>
{/each}
{#if preview.tools.total > preview.tools.items.length}
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs text-gray-500">
{$i18n.t('{{count}} of {{total}} accessible', {
count: preview.tools.items.length,
total: preview.tools.total
})}
</div>
</div>
{/if}
{/if}
</div>
</div>
{/if}
</div>
@@ -261,6 +261,7 @@
<td class=" px-3 py-1">
{dayjs(user.last_active_at * 1000).fromNow()}
</td>
</tr>
{/each}
</tbody>
@@ -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 @@
</Tooltip>
{/if}
{#if user.role !== 'admin'}
<Tooltip content={$i18n.t('Preview Access')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
aria-label={$i18n.t('Preview Access')}
on:click={() => {
selectedUser = user;
showUserPreviewModal = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Edit User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
@@ -518,3 +553,7 @@
</div>
{/if}
{/if}
{#if selectedUser}
<UserPreviewModal bind:show={showUserPreviewModal} userId={selectedUser.id} userName={selectedUser.name} />
{/if}