1
0

scim.py 27 KB

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