Coverage for open_webui/routers/skills.py: 85%
143 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1import logging
2import re
3from typing import Optional
5from fastapi import APIRouter, Depends, HTTPException, Request, status
6from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
7from open_webui.constants import ERROR_MESSAGES
8from open_webui.events import EVENTS, publish_event
9from open_webui.internal.db import get_async_session
10from open_webui.models.access_grants import AccessGrants
11from open_webui.models.config import Config
12from open_webui.models.groups import Groups
13from open_webui.models.skills import (
14 SkillAccessListResponse,
15 SkillAccessResponse,
16 SkillForm,
17 SkillModel,
18 SkillResponse,
19 Skills,
20 SkillUserResponse,
21)
22from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
23from open_webui.utils.auth import get_admin_user, get_verified_user
24from pydantic import BaseModel
25from sqlalchemy.ext.asyncio import AsyncSession
27log = logging.getLogger(__name__)
29PAGE_ITEM_COUNT = 30
31router = APIRouter()
34############################
35# GetSkills
36############################
39@router.get('/', response_model=list[SkillUserResponse])
40async def get_skills(
41 request: Request,
42 query: Optional[str] = None,
43 user=Depends(get_verified_user),
44 db: AsyncSession = Depends(get_async_session),
45):
46 if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: 46 ↛ 49line 46 didn't jump to line 49 because the condition on line 46 was always true
47 skills = await Skills.get_skills(db=db)
48 else:
49 skills = await Skills.get_skills(db=db, user_id=user.id)
51 if query:
52 q = query.casefold()
53 skills = [skill for skill in skills if q in (skill.name or '').casefold()]
55 return skills
58############################
59# GetSkillList
60############################
63@router.get('/list', response_model=SkillAccessListResponse)
64async def get_skill_list(
65 query: Optional[str] = None,
66 view_option: Optional[str] = None,
67 order_by: Optional[str] = None,
68 direction: Optional[str] = None,
69 page: Optional[int] = 1,
70 user=Depends(get_verified_user),
71 db: AsyncSession = Depends(get_async_session),
72):
73 limit = PAGE_ITEM_COUNT
75 page = max(1, page)
76 skip = (page - 1) * limit
78 filter = {}
79 if query:
80 filter['query'] = query
81 if view_option:
82 filter['view_option'] = view_option
83 if order_by:
84 filter['order_by'] = order_by
85 if direction:
86 filter['direction'] = direction
88 is_bypass_admin = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
89 user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
91 if not is_bypass_admin: 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true
92 filter['group_ids'] = user_group_ids
93 filter['user_id'] = user.id
95 result = await Skills.search_skills(user.id, filter=filter, skip=skip, limit=limit, db=db)
97 writable_skill_ids = await AccessGrants.get_accessible_resource_ids(
98 user_id=user.id,
99 resource_type='skill',
100 resource_ids=[skill.id for skill in result.items],
101 permission='write',
102 user_group_ids=user_group_ids,
103 db=db,
104 )
106 return SkillAccessListResponse(
107 items=[
108 SkillAccessResponse(
109 **skill.model_dump(),
110 write_access=(is_bypass_admin or user.id == skill.user_id or skill.id in writable_skill_ids),
111 )
112 for skill in result.items
113 ],
114 total=result.total,
115 )
118############################
119# ExportSkills
120############################
123@router.get('/export', response_model=list[SkillModel])
124async def export_skills(
125 request: Request,
126 user=Depends(get_verified_user),
127 db: AsyncSession = Depends(get_async_session),
128):
129 if user.role != 'admin' and not await has_permission( 129 ↛ 135line 129 didn't jump to line 135 because the condition on line 129 was never true
130 user.id,
131 'workspace.skills_export',
132 await Config.get('user.permissions'),
133 db=db,
134 ):
135 raise HTTPException(
136 status_code=status.HTTP_401_UNAUTHORIZED,
137 detail=ERROR_MESSAGES.UNAUTHORIZED,
138 )
140 if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: 140 ↛ 143line 140 didn't jump to line 143 because the condition on line 140 was always true
141 return await Skills.get_skills(db=db)
142 else:
143 return await Skills.get_skills(db=db, user_id=user.id)
146############################
147# CreateNewSkill
148############################
151@router.post('/create', response_model=Optional[SkillResponse])
152async def create_new_skill(
153 request: Request,
154 form_data: SkillForm,
155 user=Depends(get_verified_user),
156 db: AsyncSession = Depends(get_async_session),
157):
158 if user.role != 'admin' and not ( 158 ↛ 162line 158 didn't jump to line 162 because the condition on line 158 was never true
159 await has_permission(user.id, 'workspace.skills', await Config.get('user.permissions'), db=db)
160 or await has_permission(user.id, 'workspace.skills_import', await Config.get('user.permissions'), db=db)
161 ):
162 raise HTTPException(
163 status_code=status.HTTP_401_UNAUTHORIZED,
164 detail=ERROR_MESSAGES.UNAUTHORIZED,
165 )
167 form_data.id = form_data.id.lower().replace(' ', '-')
169 # The id goes into /id/{id}/... paths, so anything outside the slug charset is unreachable once stored.
170 if not re.fullmatch(r'[a-z0-9_-]+', form_data.id):
171 raise HTTPException(
172 status_code=status.HTTP_400_BAD_REQUEST,
173 detail=ERROR_MESSAGES.DEFAULT('Invalid skill ID'),
174 )
176 existing = await Skills.get_skill_by_id(form_data.id, db=db)
177 if existing is not None:
178 raise HTTPException(
179 status_code=status.HTTP_400_BAD_REQUEST,
180 detail=ERROR_MESSAGES.ID_TAKEN,
181 )
183 # Strip public/user grants the requesting user is not permitted to assign
184 # (matches the channel/notes/calendar pattern). Without this, a user with
185 # workspace.skills permission could attach principal_id='*' read/write
186 # grants in the create payload, bypassing the sharing.public_skills gate
187 # that the dedicated /access/update endpoint already enforces.
188 form_data.access_grants = await filter_allowed_access_grants(
189 await Config.get('user.permissions'),
190 user.id,
191 user.role,
192 form_data.access_grants,
193 'sharing.public_skills',
194 )
196 try:
197 skill = await Skills.insert_new_skill(user.id, form_data, db=db)
198 if skill:
199 await publish_event(
200 request,
201 EVENTS.SKILL_CREATED,
202 actor=user,
203 subject_id=skill.id,
204 data={'name': skill.name},
205 )
206 return skill
207 else:
208 raise HTTPException(
209 status_code=status.HTTP_400_BAD_REQUEST,
210 detail=ERROR_MESSAGES.DEFAULT('Error creating skill'),
211 )
212 except HTTPException:
213 raise
214 except Exception as e:
215 log.exception(f'Failed to create skill: {e}')
216 raise HTTPException(
217 status_code=status.HTTP_400_BAD_REQUEST,
218 detail=ERROR_MESSAGES.DEFAULT(e, 'Error creating skill'),
219 )
222############################
223# GetSkillById
224############################
227@router.get('/id/{id}', response_model=Optional[SkillAccessResponse])
228async def get_skill_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
229 skill = await Skills.get_skill_by_id(id, db=db)
231 if skill:
232 if ( 232 ↛ 258line 232 didn't jump to line 258 because the condition on line 232 was always true
233 user.role == 'admin'
234 or skill.user_id == user.id
235 or await AccessGrants.has_access(
236 user_id=user.id,
237 resource_type='skill',
238 resource_id=skill.id,
239 permission='read',
240 db=db,
241 )
242 ):
243 return SkillAccessResponse(
244 **skill.model_dump(),
245 write_access=(
246 (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
247 or user.id == skill.user_id
248 or await AccessGrants.has_access(
249 user_id=user.id,
250 resource_type='skill',
251 resource_id=skill.id,
252 permission='write',
253 db=db,
254 )
255 ),
256 )
257 else:
258 raise HTTPException(
259 status_code=status.HTTP_401_UNAUTHORIZED,
260 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
261 )
262 else:
263 raise HTTPException(
264 status_code=status.HTTP_404_NOT_FOUND,
265 detail=ERROR_MESSAGES.NOT_FOUND,
266 )
269############################
270# UpdateSkillById
271############################
274@router.post('/id/{id}/update', response_model=Optional[SkillModel])
275async def update_skill_by_id(
276 request: Request,
277 id: str,
278 form_data: SkillForm,
279 user=Depends(get_verified_user),
280 db: AsyncSession = Depends(get_async_session),
281):
282 skill = await Skills.get_skill_by_id(id, db=db)
283 if not skill:
284 raise HTTPException(
285 status_code=status.HTTP_404_NOT_FOUND,
286 detail=ERROR_MESSAGES.NOT_FOUND,
287 )
289 if ( 289 ↛ 300line 289 didn't jump to line 300 because the condition on line 289 was never true
290 skill.user_id != user.id
291 and not await AccessGrants.has_access(
292 user_id=user.id,
293 resource_type='skill',
294 resource_id=skill.id,
295 permission='write',
296 db=db,
297 )
298 and user.role != 'admin'
299 ):
300 raise HTTPException(
301 status_code=status.HTTP_401_UNAUTHORIZED,
302 detail=ERROR_MESSAGES.UNAUTHORIZED,
303 )
305 # Strip public/user grants the requesting user is not permitted to assign
306 # (matches the channel/notes/calendar pattern). The access check above only
307 # restricts WHO can write to the skill; this filter restricts WHICH grants
308 # they may set, so a non-admin owner cannot make their own skill publicly
309 # readable/writable without sharing.public_skills permission.
310 form_data.access_grants = await filter_allowed_access_grants(
311 await Config.get('user.permissions'),
312 user.id,
313 user.role,
314 form_data.access_grants,
315 'sharing.public_skills',
316 )
318 try:
319 updated = {
320 **form_data.model_dump(exclude={'id'}),
321 }
323 skill = await Skills.update_skill_by_id(id, updated, db=db)
325 if skill:
326 await publish_event(
327 request,
328 EVENTS.SKILL_UPDATED,
329 actor=user,
330 subject_id=skill.id,
331 data={'name': skill.name},
332 )
333 return skill
334 else:
335 raise HTTPException(
336 status_code=status.HTTP_400_BAD_REQUEST,
337 detail=ERROR_MESSAGES.DEFAULT('Error updating skill'),
338 )
339 except HTTPException:
340 raise
341 except Exception as e:
342 raise HTTPException(
343 status_code=status.HTTP_400_BAD_REQUEST,
344 detail=ERROR_MESSAGES.DEFAULT(e, 'Error updating skill'),
345 )
348############################
349# UpdateSkillAccessById
350############################
353class SkillAccessGrantsForm(BaseModel):
354 access_grants: list[dict]
357@router.post('/id/{id}/access/update', response_model=Optional[SkillModel])
358async def update_skill_access_by_id(
359 request: Request,
360 id: str,
361 form_data: SkillAccessGrantsForm,
362 user=Depends(get_verified_user),
363 db: AsyncSession = Depends(get_async_session),
364):
365 skill = await Skills.get_skill_by_id(id, db=db)
366 if not skill:
367 raise HTTPException(
368 status_code=status.HTTP_404_NOT_FOUND,
369 detail=ERROR_MESSAGES.NOT_FOUND,
370 )
372 if ( 372 ↛ 383line 372 didn't jump to line 383 because the condition on line 372 was never true
373 skill.user_id != user.id
374 and not await AccessGrants.has_access(
375 user_id=user.id,
376 resource_type='skill',
377 resource_id=skill.id,
378 permission='write',
379 db=db,
380 )
381 and user.role != 'admin'
382 ):
383 raise HTTPException(
384 status_code=status.HTTP_401_UNAUTHORIZED,
385 detail=ERROR_MESSAGES.UNAUTHORIZED,
386 )
388 form_data.access_grants = await filter_allowed_access_grants(
389 await Config.get('user.permissions'),
390 user.id,
391 user.role,
392 form_data.access_grants,
393 'sharing.public_skills',
394 )
396 await AccessGrants.set_access_grants('skill', id, form_data.access_grants, db=db)
398 skill = await Skills.get_skill_by_id(id, db=db)
399 await publish_event(
400 request,
401 EVENTS.SKILL_UPDATED,
402 actor=user,
403 subject_id=id,
404 data={'access_updated': True, 'name': skill.name if skill else None},
405 )
406 return skill
409############################
410# ToggleSkillById
411############################
414@router.post('/id/{id}/toggle', response_model=Optional[SkillModel])
415async def toggle_skill_by_id(
416 request: Request,
417 id: str,
418 user=Depends(get_verified_user),
419 db: AsyncSession = Depends(get_async_session),
420):
421 skill = await Skills.get_skill_by_id(id, db=db)
422 if skill:
423 if ( 423 ↛ 451line 423 didn't jump to line 451 because the condition on line 423 was always true
424 user.role == 'admin'
425 or skill.user_id == user.id
426 or await AccessGrants.has_access(
427 user_id=user.id,
428 resource_type='skill',
429 resource_id=skill.id,
430 permission='write',
431 db=db,
432 )
433 ):
434 skill = await Skills.toggle_skill_by_id(id, db=db)
436 if skill: 436 ↛ 446line 436 didn't jump to line 446 because the condition on line 436 was always true
437 await publish_event(
438 request,
439 EVENTS.SKILL_ENABLED if skill.is_active else EVENTS.SKILL_DISABLED,
440 actor=user,
441 subject_id=skill.id,
442 data={'name': skill.name},
443 )
444 return skill
445 else:
446 raise HTTPException(
447 status_code=status.HTTP_400_BAD_REQUEST,
448 detail=ERROR_MESSAGES.DEFAULT('Error toggling skill'),
449 )
450 else:
451 raise HTTPException(
452 status_code=status.HTTP_401_UNAUTHORIZED,
453 detail=ERROR_MESSAGES.UNAUTHORIZED,
454 )
455 else:
456 raise HTTPException(
457 status_code=status.HTTP_404_NOT_FOUND,
458 detail=ERROR_MESSAGES.NOT_FOUND,
459 )
462############################
463# DeleteSkillById
464############################
467@router.delete('/id/{id}/delete', response_model=bool)
468async def delete_skill_by_id(
469 request: Request,
470 id: str,
471 user=Depends(get_verified_user),
472 db: AsyncSession = Depends(get_async_session),
473):
474 skill = await Skills.get_skill_by_id(id, db=db)
475 if not skill:
476 raise HTTPException(
477 status_code=status.HTTP_404_NOT_FOUND,
478 detail=ERROR_MESSAGES.NOT_FOUND,
479 )
481 if ( 481 ↛ 492line 481 didn't jump to line 492 because the condition on line 481 was never true
482 skill.user_id != user.id
483 and not await AccessGrants.has_access(
484 user_id=user.id,
485 resource_type='skill',
486 resource_id=skill.id,
487 permission='write',
488 db=db,
489 )
490 and user.role != 'admin'
491 ):
492 raise HTTPException(
493 status_code=status.HTTP_401_UNAUTHORIZED,
494 detail=ERROR_MESSAGES.UNAUTHORIZED,
495 )
497 result = await Skills.delete_skill_by_id(id, db=db)
498 if result: 498 ↛ 506line 498 didn't jump to line 506 because the condition on line 498 was always true
499 await publish_event(
500 request,
501 EVENTS.SKILL_DELETED,
502 actor=user,
503 subject_id=id,
504 data={'name': skill.name},
505 )
506 return result