scim.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917
  1. """
  2. SCIM 2.0 Implementation for Open WebUI
  3. Provides System for Cross-domain Identity Management endpoints for users and groups
  4. """
  5. import logging
  6. import uuid
  7. import time
  8. from typing import Optional, List, Dict, Any
  9. from datetime import datetime, timezone
  10. from fastapi import APIRouter, Depends, HTTPException, Request, Query, Header, status
  11. from fastapi.responses import JSONResponse
  12. from pydantic import BaseModel, Field, ConfigDict
  13. from open_webui.models.users import Users, UserModel
  14. from open_webui.models.groups import Groups, GroupModel
  15. from open_webui.utils.auth import get_admin_user, get_current_user, decode_token, get_verified_user
  16. from open_webui.constants import ERROR_MESSAGES
  17. from open_webui.env import SRC_LOG_LEVELS
  18. log = logging.getLogger(__name__)
  19. log.setLevel(SRC_LOG_LEVELS["MAIN"])
  20. router = APIRouter()
  21. # SCIM 2.0 Schema URIs
  22. SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
  23. SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
  24. SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  25. SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"
  26. # SCIM Resource Types
  27. SCIM_RESOURCE_TYPE_USER = "User"
  28. SCIM_RESOURCE_TYPE_GROUP = "Group"
  29. def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None):
  30. """Create a SCIM-compliant error response"""
  31. error_body = {
  32. "schemas": [SCIM_ERROR_SCHEMA],
  33. "status": str(status_code),
  34. "detail": detail
  35. }
  36. if scim_type:
  37. error_body["scimType"] = scim_type
  38. elif status_code == 404:
  39. error_body["scimType"] = "invalidValue"
  40. elif status_code == 409:
  41. error_body["scimType"] = "uniqueness"
  42. elif status_code == 400:
  43. error_body["scimType"] = "invalidSyntax"
  44. return JSONResponse(
  45. status_code=status_code,
  46. content=error_body
  47. )
  48. class SCIMError(BaseModel):
  49. """SCIM Error Response"""
  50. schemas: List[str] = [SCIM_ERROR_SCHEMA]
  51. status: str
  52. scimType: Optional[str] = None
  53. detail: Optional[str] = None
  54. class SCIMMeta(BaseModel):
  55. """SCIM Resource Metadata"""
  56. resourceType: str
  57. created: str
  58. lastModified: str
  59. location: Optional[str] = None
  60. version: Optional[str] = None
  61. class SCIMName(BaseModel):
  62. """SCIM User Name"""
  63. formatted: Optional[str] = None
  64. familyName: Optional[str] = None
  65. givenName: Optional[str] = None
  66. middleName: Optional[str] = None
  67. honorificPrefix: Optional[str] = None
  68. honorificSuffix: Optional[str] = None
  69. class SCIMEmail(BaseModel):
  70. """SCIM Email"""
  71. value: str
  72. type: Optional[str] = "work"
  73. primary: bool = True
  74. display: Optional[str] = None
  75. class SCIMPhoto(BaseModel):
  76. """SCIM Photo"""
  77. value: str
  78. type: Optional[str] = "photo"
  79. primary: bool = True
  80. display: Optional[str] = None
  81. class SCIMGroupMember(BaseModel):
  82. """SCIM Group Member"""
  83. value: str # User ID
  84. ref: Optional[str] = Field(None, alias="$ref")
  85. type: Optional[str] = "User"
  86. display: Optional[str] = None
  87. class SCIMUser(BaseModel):
  88. """SCIM User Resource"""
  89. model_config = ConfigDict(populate_by_name=True)
  90. schemas: List[str] = [SCIM_USER_SCHEMA]
  91. id: str
  92. externalId: Optional[str] = None
  93. userName: str
  94. name: Optional[SCIMName] = None
  95. displayName: str
  96. emails: List[SCIMEmail]
  97. active: bool = True
  98. photos: Optional[List[SCIMPhoto]] = None
  99. groups: Optional[List[Dict[str, str]]] = None
  100. meta: SCIMMeta
  101. class SCIMUserCreateRequest(BaseModel):
  102. """SCIM User Create Request"""
  103. model_config = ConfigDict(populate_by_name=True)
  104. schemas: List[str] = [SCIM_USER_SCHEMA]
  105. externalId: Optional[str] = None
  106. userName: str
  107. name: Optional[SCIMName] = None
  108. displayName: str
  109. emails: List[SCIMEmail]
  110. active: bool = True
  111. password: Optional[str] = None
  112. photos: Optional[List[SCIMPhoto]] = None
  113. class SCIMUserUpdateRequest(BaseModel):
  114. """SCIM User Update Request"""
  115. model_config = ConfigDict(populate_by_name=True)
  116. schemas: List[str] = [SCIM_USER_SCHEMA]
  117. id: Optional[str] = None
  118. externalId: Optional[str] = None
  119. userName: Optional[str] = None
  120. name: Optional[SCIMName] = None
  121. displayName: Optional[str] = None
  122. emails: Optional[List[SCIMEmail]] = None
  123. active: Optional[bool] = None
  124. photos: Optional[List[SCIMPhoto]] = None
  125. class SCIMGroup(BaseModel):
  126. """SCIM Group Resource"""
  127. model_config = ConfigDict(populate_by_name=True)
  128. schemas: List[str] = [SCIM_GROUP_SCHEMA]
  129. id: str
  130. displayName: str
  131. members: Optional[List[SCIMGroupMember]] = []
  132. meta: SCIMMeta
  133. class SCIMGroupCreateRequest(BaseModel):
  134. """SCIM Group Create Request"""
  135. model_config = ConfigDict(populate_by_name=True)
  136. schemas: List[str] = [SCIM_GROUP_SCHEMA]
  137. displayName: str
  138. members: Optional[List[SCIMGroupMember]] = []
  139. class SCIMGroupUpdateRequest(BaseModel):
  140. """SCIM Group Update Request"""
  141. model_config = ConfigDict(populate_by_name=True)
  142. schemas: List[str] = [SCIM_GROUP_SCHEMA]
  143. displayName: Optional[str] = None
  144. members: Optional[List[SCIMGroupMember]] = None
  145. class SCIMListResponse(BaseModel):
  146. """SCIM List Response"""
  147. schemas: List[str] = [SCIM_LIST_RESPONSE_SCHEMA]
  148. totalResults: int
  149. itemsPerPage: int
  150. startIndex: int
  151. Resources: List[Any]
  152. class SCIMPatchOperation(BaseModel):
  153. """SCIM Patch Operation"""
  154. op: str # "add", "replace", "remove"
  155. path: Optional[str] = None
  156. value: Optional[Any] = None
  157. class SCIMPatchRequest(BaseModel):
  158. """SCIM Patch Request"""
  159. schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
  160. Operations: List[SCIMPatchOperation]
  161. def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) -> bool:
  162. """
  163. Verify SCIM authentication
  164. Checks for SCIM-specific bearer token configured in the system
  165. """
  166. if not authorization:
  167. raise HTTPException(
  168. status_code=status.HTTP_401_UNAUTHORIZED,
  169. detail="Authorization header required",
  170. headers={"WWW-Authenticate": "Bearer"},
  171. )
  172. try:
  173. parts = authorization.split()
  174. if len(parts) != 2:
  175. raise HTTPException(
  176. status_code=status.HTTP_401_UNAUTHORIZED,
  177. detail="Invalid authorization format. Expected: Bearer <token>",
  178. )
  179. scheme, token = parts
  180. if scheme.lower() != "bearer":
  181. raise HTTPException(
  182. status_code=status.HTTP_401_UNAUTHORIZED,
  183. detail="Invalid authentication scheme",
  184. )
  185. # Check if SCIM is enabled
  186. scim_enabled = getattr(request.app.state.config, "SCIM_ENABLED", False)
  187. log.info(f"SCIM auth check - raw SCIM_ENABLED: {scim_enabled}, type: {type(scim_enabled)}")
  188. # Handle both PersistentConfig and direct value
  189. if hasattr(scim_enabled, 'value'):
  190. scim_enabled = scim_enabled.value
  191. log.info(f"SCIM enabled status after conversion: {scim_enabled}")
  192. if not scim_enabled:
  193. raise HTTPException(
  194. status_code=status.HTTP_403_FORBIDDEN,
  195. detail="SCIM is not enabled",
  196. )
  197. # Verify the SCIM token
  198. scim_token = getattr(request.app.state.config, "SCIM_TOKEN", None)
  199. # Handle both PersistentConfig and direct value
  200. if hasattr(scim_token, 'value'):
  201. scim_token = scim_token.value
  202. log.debug(f"SCIM token configured: {bool(scim_token)}")
  203. if not scim_token or token != scim_token:
  204. raise HTTPException(
  205. status_code=status.HTTP_401_UNAUTHORIZED,
  206. detail="Invalid SCIM token",
  207. )
  208. return True
  209. except HTTPException:
  210. # Re-raise HTTP exceptions as-is
  211. raise
  212. except Exception as e:
  213. log.error(f"SCIM authentication error: {e}")
  214. import traceback
  215. log.error(f"Traceback: {traceback.format_exc()}")
  216. raise HTTPException(
  217. status_code=status.HTTP_401_UNAUTHORIZED,
  218. detail="Authentication failed",
  219. )
  220. def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
  221. """Convert internal User model to SCIM User"""
  222. # Parse display name into name components
  223. name_parts = user.name.split(" ", 1) if user.name else ["", ""]
  224. given_name = name_parts[0] if name_parts else ""
  225. family_name = name_parts[1] if len(name_parts) > 1 else ""
  226. # Get user's groups
  227. user_groups = Groups.get_groups_by_member_id(user.id)
  228. groups = [
  229. {
  230. "value": group.id,
  231. "display": group.name,
  232. "$ref": f"{request.base_url}api/v1/scim/v2/Groups/{group.id}",
  233. "type": "direct"
  234. }
  235. for group in user_groups
  236. ]
  237. return SCIMUser(
  238. id=user.id,
  239. userName=user.email,
  240. name=SCIMName(
  241. formatted=user.name,
  242. givenName=given_name,
  243. familyName=family_name,
  244. ),
  245. displayName=user.name,
  246. emails=[SCIMEmail(value=user.email)],
  247. active=user.role != "pending",
  248. photos=[SCIMPhoto(value=user.profile_image_url)] if user.profile_image_url else None,
  249. groups=groups if groups else None,
  250. meta=SCIMMeta(
  251. resourceType=SCIM_RESOURCE_TYPE_USER,
  252. created=datetime.fromtimestamp(user.created_at, tz=timezone.utc).isoformat(),
  253. lastModified=datetime.fromtimestamp(user.updated_at, tz=timezone.utc).isoformat(),
  254. location=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
  255. ),
  256. )
  257. def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup:
  258. """Convert internal Group model to SCIM Group"""
  259. members = []
  260. for user_id in group.user_ids:
  261. user = Users.get_user_by_id(user_id)
  262. if user:
  263. members.append(
  264. SCIMGroupMember(
  265. value=user.id,
  266. ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
  267. display=user.name,
  268. )
  269. )
  270. return SCIMGroup(
  271. id=group.id,
  272. displayName=group.name,
  273. members=members,
  274. meta=SCIMMeta(
  275. resourceType=SCIM_RESOURCE_TYPE_GROUP,
  276. created=datetime.fromtimestamp(group.created_at, tz=timezone.utc).isoformat(),
  277. lastModified=datetime.fromtimestamp(group.updated_at, tz=timezone.utc).isoformat(),
  278. location=f"{request.base_url}api/v1/scim/v2/Groups/{group.id}",
  279. ),
  280. )
  281. # SCIM Service Provider Config
  282. @router.get("/ServiceProviderConfig")
  283. async def get_service_provider_config():
  284. """Get SCIM Service Provider Configuration"""
  285. return {
  286. "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
  287. "patch": {
  288. "supported": True
  289. },
  290. "bulk": {
  291. "supported": False,
  292. "maxOperations": 1000,
  293. "maxPayloadSize": 1048576
  294. },
  295. "filter": {
  296. "supported": True,
  297. "maxResults": 200
  298. },
  299. "changePassword": {
  300. "supported": False
  301. },
  302. "sort": {
  303. "supported": False
  304. },
  305. "etag": {
  306. "supported": False
  307. },
  308. "authenticationSchemes": [
  309. {
  310. "type": "oauthbearertoken",
  311. "name": "OAuth Bearer Token",
  312. "description": "Authentication using OAuth 2.0 Bearer Token"
  313. }
  314. ]
  315. }
  316. # SCIM Resource Types
  317. @router.get("/ResourceTypes")
  318. async def get_resource_types(request: Request):
  319. """Get SCIM Resource Types"""
  320. return [
  321. {
  322. "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
  323. "id": "User",
  324. "name": "User",
  325. "endpoint": "/Users",
  326. "schema": SCIM_USER_SCHEMA,
  327. "meta": {
  328. "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/User",
  329. "resourceType": "ResourceType"
  330. }
  331. },
  332. {
  333. "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
  334. "id": "Group",
  335. "name": "Group",
  336. "endpoint": "/Groups",
  337. "schema": SCIM_GROUP_SCHEMA,
  338. "meta": {
  339. "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/Group",
  340. "resourceType": "ResourceType"
  341. }
  342. }
  343. ]
  344. # SCIM Schemas
  345. @router.get("/Schemas")
  346. async def get_schemas():
  347. """Get SCIM Schemas"""
  348. return [
  349. {
  350. "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
  351. "id": SCIM_USER_SCHEMA,
  352. "name": "User",
  353. "description": "User Account",
  354. "attributes": [
  355. {
  356. "name": "userName",
  357. "type": "string",
  358. "required": True,
  359. "uniqueness": "server"
  360. },
  361. {
  362. "name": "displayName",
  363. "type": "string",
  364. "required": True
  365. },
  366. {
  367. "name": "emails",
  368. "type": "complex",
  369. "multiValued": True,
  370. "required": True
  371. },
  372. {
  373. "name": "active",
  374. "type": "boolean",
  375. "required": False
  376. }
  377. ]
  378. },
  379. {
  380. "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
  381. "id": SCIM_GROUP_SCHEMA,
  382. "name": "Group",
  383. "description": "Group",
  384. "attributes": [
  385. {
  386. "name": "displayName",
  387. "type": "string",
  388. "required": True
  389. },
  390. {
  391. "name": "members",
  392. "type": "complex",
  393. "multiValued": True,
  394. "required": False
  395. }
  396. ]
  397. }
  398. ]
  399. # Users endpoints
  400. @router.get("/Users", response_model=SCIMListResponse)
  401. async def get_users(
  402. request: Request,
  403. startIndex: int = Query(1, ge=1),
  404. count: int = Query(20, ge=1, le=100),
  405. filter: Optional[str] = None,
  406. _: bool = Depends(get_scim_auth),
  407. ):
  408. """List SCIM Users"""
  409. skip = startIndex - 1
  410. limit = count
  411. # Get users from database
  412. if filter:
  413. # Simple filter parsing - supports userName eq "email"
  414. # In production, you'd want a more robust filter parser
  415. if "userName eq" in filter:
  416. email = filter.split('"')[1]
  417. user = Users.get_user_by_email(email)
  418. users_list = [user] if user else []
  419. total = 1 if user else 0
  420. else:
  421. response = Users.get_users(skip=skip, limit=limit)
  422. users_list = response["users"]
  423. total = response["total"]
  424. else:
  425. response = Users.get_users(skip=skip, limit=limit)
  426. users_list = response["users"]
  427. total = response["total"]
  428. # Convert to SCIM format
  429. scim_users = [user_to_scim(user, request) for user in users_list]
  430. return SCIMListResponse(
  431. totalResults=total,
  432. itemsPerPage=len(scim_users),
  433. startIndex=startIndex,
  434. Resources=scim_users,
  435. )
  436. @router.get("/Users/{user_id}", response_model=SCIMUser)
  437. async def get_user(
  438. user_id: str,
  439. request: Request,
  440. _: bool = Depends(get_scim_auth),
  441. ):
  442. """Get SCIM User by ID"""
  443. user = Users.get_user_by_id(user_id)
  444. if not user:
  445. return scim_error(
  446. status_code=status.HTTP_404_NOT_FOUND,
  447. detail=f"User {user_id} not found"
  448. )
  449. return user_to_scim(user, request)
  450. @router.post("/Users", response_model=SCIMUser, status_code=status.HTTP_201_CREATED)
  451. async def create_user(
  452. request: Request,
  453. user_data: SCIMUserCreateRequest,
  454. _: bool = Depends(get_scim_auth),
  455. ):
  456. """Create SCIM User"""
  457. # Check if user already exists
  458. existing_user = Users.get_user_by_email(user_data.userName)
  459. if existing_user:
  460. raise HTTPException(
  461. status_code=status.HTTP_409_CONFLICT,
  462. detail=f"User with email {user_data.userName} already exists",
  463. )
  464. # Create user
  465. user_id = str(uuid.uuid4())
  466. email = user_data.emails[0].value if user_data.emails else user_data.userName
  467. # Parse name if provided
  468. name = user_data.displayName
  469. if user_data.name:
  470. if user_data.name.formatted:
  471. name = user_data.name.formatted
  472. elif user_data.name.givenName or user_data.name.familyName:
  473. name = f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip()
  474. # Get profile image if provided
  475. profile_image = "/user.png"
  476. if user_data.photos and len(user_data.photos) > 0:
  477. profile_image = user_data.photos[0].value
  478. # Create user
  479. new_user = Users.insert_new_user(
  480. id=user_id,
  481. name=name,
  482. email=email,
  483. profile_image_url=profile_image,
  484. role="user" if user_data.active else "pending",
  485. )
  486. if not new_user:
  487. raise HTTPException(
  488. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  489. detail="Failed to create user",
  490. )
  491. return user_to_scim(new_user, request)
  492. @router.put("/Users/{user_id}", response_model=SCIMUser)
  493. async def update_user(
  494. user_id: str,
  495. request: Request,
  496. user_data: SCIMUserUpdateRequest,
  497. _: bool = Depends(get_scim_auth),
  498. ):
  499. """Update SCIM User (full update)"""
  500. user = Users.get_user_by_id(user_id)
  501. if not user:
  502. raise HTTPException(
  503. status_code=status.HTTP_404_NOT_FOUND,
  504. detail=f"User {user_id} not found",
  505. )
  506. # Build update dict
  507. update_data = {}
  508. if user_data.userName:
  509. update_data["email"] = user_data.userName
  510. if user_data.displayName:
  511. update_data["name"] = user_data.displayName
  512. elif user_data.name:
  513. if user_data.name.formatted:
  514. update_data["name"] = user_data.name.formatted
  515. elif user_data.name.givenName or user_data.name.familyName:
  516. update_data["name"] = f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip()
  517. if user_data.emails and len(user_data.emails) > 0:
  518. update_data["email"] = user_data.emails[0].value
  519. if user_data.active is not None:
  520. update_data["role"] = "user" if user_data.active else "pending"
  521. if user_data.photos and len(user_data.photos) > 0:
  522. update_data["profile_image_url"] = user_data.photos[0].value
  523. # Update user
  524. updated_user = Users.update_user_by_id(user_id, update_data)
  525. if not updated_user:
  526. raise HTTPException(
  527. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  528. detail="Failed to update user",
  529. )
  530. return user_to_scim(updated_user, request)
  531. @router.patch("/Users/{user_id}", response_model=SCIMUser)
  532. async def patch_user(
  533. user_id: str,
  534. request: Request,
  535. patch_data: SCIMPatchRequest,
  536. _: bool = Depends(get_scim_auth),
  537. ):
  538. """Update SCIM User (partial update)"""
  539. user = Users.get_user_by_id(user_id)
  540. if not user:
  541. raise HTTPException(
  542. status_code=status.HTTP_404_NOT_FOUND,
  543. detail=f"User {user_id} not found",
  544. )
  545. update_data = {}
  546. for operation in patch_data.Operations:
  547. op = operation.op.lower()
  548. path = operation.path
  549. value = operation.value
  550. if op == "replace":
  551. if path == "active":
  552. update_data["role"] = "user" if value else "pending"
  553. elif path == "userName":
  554. update_data["email"] = value
  555. elif path == "displayName":
  556. update_data["name"] = value
  557. elif path == "emails[primary eq true].value":
  558. update_data["email"] = value
  559. elif path == "name.formatted":
  560. update_data["name"] = value
  561. # Update user
  562. if update_data:
  563. updated_user = Users.update_user_by_id(user_id, update_data)
  564. if not updated_user:
  565. raise HTTPException(
  566. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  567. detail="Failed to update user",
  568. )
  569. else:
  570. updated_user = user
  571. return user_to_scim(updated_user, request)
  572. @router.delete("/Users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
  573. async def delete_user(
  574. user_id: str,
  575. request: Request,
  576. _: bool = Depends(get_scim_auth),
  577. ):
  578. """Delete SCIM User"""
  579. user = Users.get_user_by_id(user_id)
  580. if not user:
  581. raise HTTPException(
  582. status_code=status.HTTP_404_NOT_FOUND,
  583. detail=f"User {user_id} not found",
  584. )
  585. success = Users.delete_user_by_id(user_id)
  586. if not success:
  587. raise HTTPException(
  588. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  589. detail="Failed to delete user",
  590. )
  591. return None
  592. # Groups endpoints
  593. @router.get("/Groups", response_model=SCIMListResponse)
  594. async def get_groups(
  595. request: Request,
  596. startIndex: int = Query(1, ge=1),
  597. count: int = Query(20, ge=1, le=100),
  598. filter: Optional[str] = None,
  599. _: bool = Depends(get_scim_auth),
  600. ):
  601. """List SCIM Groups"""
  602. # Get all groups
  603. groups_list = Groups.get_groups()
  604. # Apply pagination
  605. total = len(groups_list)
  606. start = startIndex - 1
  607. end = start + count
  608. paginated_groups = groups_list[start:end]
  609. # Convert to SCIM format
  610. scim_groups = [group_to_scim(group, request) for group in paginated_groups]
  611. return SCIMListResponse(
  612. totalResults=total,
  613. itemsPerPage=len(scim_groups),
  614. startIndex=startIndex,
  615. Resources=scim_groups,
  616. )
  617. @router.get("/Groups/{group_id}", response_model=SCIMGroup)
  618. async def get_group(
  619. group_id: str,
  620. request: Request,
  621. _: bool = Depends(get_scim_auth),
  622. ):
  623. """Get SCIM Group by ID"""
  624. group = Groups.get_group_by_id(group_id)
  625. if not group:
  626. raise HTTPException(
  627. status_code=status.HTTP_404_NOT_FOUND,
  628. detail=f"Group {group_id} not found",
  629. )
  630. return group_to_scim(group, request)
  631. @router.post("/Groups", response_model=SCIMGroup, status_code=status.HTTP_201_CREATED)
  632. async def create_group(
  633. request: Request,
  634. group_data: SCIMGroupCreateRequest,
  635. _: bool = Depends(get_scim_auth),
  636. ):
  637. """Create SCIM Group"""
  638. # Extract member IDs
  639. member_ids = []
  640. if group_data.members:
  641. for member in group_data.members:
  642. member_ids.append(member.value)
  643. # Create group
  644. from open_webui.models.groups import GroupForm
  645. form = GroupForm(
  646. name=group_data.displayName,
  647. description="",
  648. )
  649. # Need to get the creating user's ID - we'll use the first admin
  650. admin_user = Users.get_super_admin_user()
  651. if not admin_user:
  652. raise HTTPException(
  653. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  654. detail="No admin user found",
  655. )
  656. new_group = Groups.insert_new_group(admin_user.id, form)
  657. if not new_group:
  658. raise HTTPException(
  659. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  660. detail="Failed to create group",
  661. )
  662. # Add members if provided
  663. if member_ids:
  664. from open_webui.models.groups import GroupUpdateForm
  665. update_form = GroupUpdateForm(
  666. name=new_group.name,
  667. description=new_group.description,
  668. user_ids=member_ids,
  669. )
  670. Groups.update_group_by_id(new_group.id, update_form)
  671. new_group = Groups.get_group_by_id(new_group.id)
  672. return group_to_scim(new_group, request)
  673. @router.put("/Groups/{group_id}", response_model=SCIMGroup)
  674. async def update_group(
  675. group_id: str,
  676. request: Request,
  677. group_data: SCIMGroupUpdateRequest,
  678. _: bool = Depends(get_scim_auth),
  679. ):
  680. """Update SCIM Group (full update)"""
  681. group = Groups.get_group_by_id(group_id)
  682. if not group:
  683. raise HTTPException(
  684. status_code=status.HTTP_404_NOT_FOUND,
  685. detail=f"Group {group_id} not found",
  686. )
  687. # Build update form
  688. from open_webui.models.groups import GroupUpdateForm
  689. update_form = GroupUpdateForm(
  690. name=group_data.displayName if group_data.displayName else group.name,
  691. description=group.description,
  692. )
  693. # Handle members if provided
  694. if group_data.members is not None:
  695. member_ids = [member.value for member in group_data.members]
  696. update_form.user_ids = member_ids
  697. # Update group
  698. updated_group = Groups.update_group_by_id(group_id, update_form)
  699. if not updated_group:
  700. raise HTTPException(
  701. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  702. detail="Failed to update group",
  703. )
  704. return group_to_scim(updated_group, request)
  705. @router.patch("/Groups/{group_id}", response_model=SCIMGroup)
  706. async def patch_group(
  707. group_id: str,
  708. request: Request,
  709. patch_data: SCIMPatchRequest,
  710. _: bool = Depends(get_scim_auth),
  711. ):
  712. """Update SCIM Group (partial update)"""
  713. group = Groups.get_group_by_id(group_id)
  714. if not group:
  715. raise HTTPException(
  716. status_code=status.HTTP_404_NOT_FOUND,
  717. detail=f"Group {group_id} not found",
  718. )
  719. from open_webui.models.groups import GroupUpdateForm
  720. update_form = GroupUpdateForm(
  721. name=group.name,
  722. description=group.description,
  723. user_ids=group.user_ids.copy() if group.user_ids else [],
  724. )
  725. for operation in patch_data.Operations:
  726. op = operation.op.lower()
  727. path = operation.path
  728. value = operation.value
  729. if op == "replace":
  730. if path == "displayName":
  731. update_form.name = value
  732. elif path == "members":
  733. # Replace all members
  734. update_form.user_ids = [member["value"] for member in value]
  735. elif op == "add":
  736. if path == "members":
  737. # Add members
  738. if isinstance(value, list):
  739. for member in value:
  740. if isinstance(member, dict) and "value" in member:
  741. if member["value"] not in update_form.user_ids:
  742. update_form.user_ids.append(member["value"])
  743. elif op == "remove":
  744. if path and path.startswith("members[value eq"):
  745. # Remove specific member
  746. member_id = path.split('"')[1]
  747. if member_id in update_form.user_ids:
  748. update_form.user_ids.remove(member_id)
  749. # Update group
  750. updated_group = Groups.update_group_by_id(group_id, update_form)
  751. if not updated_group:
  752. raise HTTPException(
  753. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  754. detail="Failed to update group",
  755. )
  756. return group_to_scim(updated_group, request)
  757. @router.delete("/Groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
  758. async def delete_group(
  759. group_id: str,
  760. request: Request,
  761. _: bool = Depends(get_scim_auth),
  762. ):
  763. """Delete SCIM Group"""
  764. group = Groups.get_group_by_id(group_id)
  765. if not group:
  766. raise HTTPException(
  767. status_code=status.HTTP_404_NOT_FOUND,
  768. detail=f"Group {group_id} not found",
  769. )
  770. success = Groups.delete_group_by_id(group_id)
  771. if not success:
  772. raise HTTPException(
  773. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  774. detail="Failed to delete group",
  775. )
  776. return None