scim.py 27 KB

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