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

1import logging 

2import re 

3from typing import Optional 

4 

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 

26 

27log = logging.getLogger(__name__) 

28 

29PAGE_ITEM_COUNT = 30 

30 

31router = APIRouter() 

32 

33 

34############################ 

35# GetSkills 

36############################ 

37 

38 

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) 

50 

51 if query: 

52 q = query.casefold() 

53 skills = [skill for skill in skills if q in (skill.name or '').casefold()] 

54 

55 return skills 

56 

57 

58############################ 

59# GetSkillList 

60############################ 

61 

62 

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 

74 

75 page = max(1, page) 

76 skip = (page - 1) * limit 

77 

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 

87 

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)} 

90 

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 

94 

95 result = await Skills.search_skills(user.id, filter=filter, skip=skip, limit=limit, db=db) 

96 

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 ) 

105 

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 ) 

116 

117 

118############################ 

119# ExportSkills 

120############################ 

121 

122 

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 ) 

139 

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) 

144 

145 

146############################ 

147# CreateNewSkill 

148############################ 

149 

150 

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 ) 

166 

167 form_data.id = form_data.id.lower().replace(' ', '-') 

168 

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 ) 

175 

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 ) 

182 

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 ) 

195 

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 ) 

220 

221 

222############################ 

223# GetSkillById 

224############################ 

225 

226 

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) 

230 

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 ) 

267 

268 

269############################ 

270# UpdateSkillById 

271############################ 

272 

273 

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 ) 

288 

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 ) 

304 

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 ) 

317 

318 try: 

319 updated = { 

320 **form_data.model_dump(exclude={'id'}), 

321 } 

322 

323 skill = await Skills.update_skill_by_id(id, updated, db=db) 

324 

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 ) 

346 

347 

348############################ 

349# UpdateSkillAccessById 

350############################ 

351 

352 

353class SkillAccessGrantsForm(BaseModel): 

354 access_grants: list[dict] 

355 

356 

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 ) 

371 

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 ) 

387 

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 ) 

395 

396 await AccessGrants.set_access_grants('skill', id, form_data.access_grants, db=db) 

397 

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 

407 

408 

409############################ 

410# ToggleSkillById 

411############################ 

412 

413 

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) 

435 

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 ) 

460 

461 

462############################ 

463# DeleteSkillById 

464############################ 

465 

466 

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 ) 

480 

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 ) 

496 

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