Timothy Jaeryang Baek 2 月之前
父節點
當前提交
7fffecd168
共有 1 個文件被更改,包括 146 次插入137 次删除
  1. 146 137
      backend/open_webui/routers/scim.py

+ 146 - 137
backend/open_webui/routers/scim.py

@@ -1,6 +1,8 @@
 """
-SCIM 2.0 Implementation for Open WebUI
+Experimental SCIM 2.0 Implementation for Open WebUI
 Provides System for Cross-domain Identity Management endpoints for users and groups
+
+NOTE: This is an experimental implementation and may not fully comply with SCIM 2.0 standards, and is subject to change.
 """
 
 import logging
@@ -15,7 +17,12 @@ from pydantic import BaseModel, Field, ConfigDict
 
 from open_webui.models.users import Users, UserModel
 from open_webui.models.groups import Groups, GroupModel
-from open_webui.utils.auth import get_admin_user, get_current_user, decode_token, get_verified_user
+from open_webui.utils.auth import (
+    get_admin_user,
+    get_current_user,
+    decode_token,
+    get_verified_user,
+)
 from open_webui.constants import ERROR_MESSAGES
 from open_webui.env import SRC_LOG_LEVELS
 
@@ -40,9 +47,9 @@ def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None):
     error_body = {
         "schemas": [SCIM_ERROR_SCHEMA],
         "status": str(status_code),
-        "detail": detail
+        "detail": detail,
     }
-    
+
     if scim_type:
         error_body["scimType"] = scim_type
     elif status_code == 404:
@@ -51,15 +58,13 @@ def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None):
         error_body["scimType"] = "uniqueness"
     elif status_code == 400:
         error_body["scimType"] = "invalidSyntax"
-    
-    return JSONResponse(
-        status_code=status_code,
-        content=error_body
-    )
+
+    return JSONResponse(status_code=status_code, content=error_body)
 
 
 class SCIMError(BaseModel):
     """SCIM Error Response"""
+
     schemas: List[str] = [SCIM_ERROR_SCHEMA]
     status: str
     scimType: Optional[str] = None
@@ -68,6 +73,7 @@ class SCIMError(BaseModel):
 
 class SCIMMeta(BaseModel):
     """SCIM Resource Metadata"""
+
     resourceType: str
     created: str
     lastModified: str
@@ -77,6 +83,7 @@ class SCIMMeta(BaseModel):
 
 class SCIMName(BaseModel):
     """SCIM User Name"""
+
     formatted: Optional[str] = None
     familyName: Optional[str] = None
     givenName: Optional[str] = None
@@ -87,6 +94,7 @@ class SCIMName(BaseModel):
 
 class SCIMEmail(BaseModel):
     """SCIM Email"""
+
     value: str
     type: Optional[str] = "work"
     primary: bool = True
@@ -95,6 +103,7 @@ class SCIMEmail(BaseModel):
 
 class SCIMPhoto(BaseModel):
     """SCIM Photo"""
+
     value: str
     type: Optional[str] = "photo"
     primary: bool = True
@@ -103,6 +112,7 @@ class SCIMPhoto(BaseModel):
 
 class SCIMGroupMember(BaseModel):
     """SCIM Group Member"""
+
     value: str  # User ID
     ref: Optional[str] = Field(None, alias="$ref")
     type: Optional[str] = "User"
@@ -111,8 +121,9 @@ class SCIMGroupMember(BaseModel):
 
 class SCIMUser(BaseModel):
     """SCIM User Resource"""
+
     model_config = ConfigDict(populate_by_name=True)
-    
+
     schemas: List[str] = [SCIM_USER_SCHEMA]
     id: str
     externalId: Optional[str] = None
@@ -128,8 +139,9 @@ class SCIMUser(BaseModel):
 
 class SCIMUserCreateRequest(BaseModel):
     """SCIM User Create Request"""
+
     model_config = ConfigDict(populate_by_name=True)
-    
+
     schemas: List[str] = [SCIM_USER_SCHEMA]
     externalId: Optional[str] = None
     userName: str
@@ -143,8 +155,9 @@ class SCIMUserCreateRequest(BaseModel):
 
 class SCIMUserUpdateRequest(BaseModel):
     """SCIM User Update Request"""
+
     model_config = ConfigDict(populate_by_name=True)
-    
+
     schemas: List[str] = [SCIM_USER_SCHEMA]
     id: Optional[str] = None
     externalId: Optional[str] = None
@@ -158,8 +171,9 @@ class SCIMUserUpdateRequest(BaseModel):
 
 class SCIMGroup(BaseModel):
     """SCIM Group Resource"""
+
     model_config = ConfigDict(populate_by_name=True)
-    
+
     schemas: List[str] = [SCIM_GROUP_SCHEMA]
     id: str
     displayName: str
@@ -169,8 +183,9 @@ class SCIMGroup(BaseModel):
 
 class SCIMGroupCreateRequest(BaseModel):
     """SCIM Group Create Request"""
+
     model_config = ConfigDict(populate_by_name=True)
-    
+
     schemas: List[str] = [SCIM_GROUP_SCHEMA]
     displayName: str
     members: Optional[List[SCIMGroupMember]] = []
@@ -178,8 +193,9 @@ class SCIMGroupCreateRequest(BaseModel):
 
 class SCIMGroupUpdateRequest(BaseModel):
     """SCIM Group Update Request"""
+
     model_config = ConfigDict(populate_by_name=True)
-    
+
     schemas: List[str] = [SCIM_GROUP_SCHEMA]
     displayName: Optional[str] = None
     members: Optional[List[SCIMGroupMember]] = None
@@ -187,6 +203,7 @@ class SCIMGroupUpdateRequest(BaseModel):
 
 class SCIMListResponse(BaseModel):
     """SCIM List Response"""
+
     schemas: List[str] = [SCIM_LIST_RESPONSE_SCHEMA]
     totalResults: int
     itemsPerPage: int
@@ -196,6 +213,7 @@ class SCIMListResponse(BaseModel):
 
 class SCIMPatchOperation(BaseModel):
     """SCIM Patch Operation"""
+
     op: str  # "add", "replace", "remove"
     path: Optional[str] = None
     value: Optional[Any] = None
@@ -203,11 +221,14 @@ class SCIMPatchOperation(BaseModel):
 
 class SCIMPatchRequest(BaseModel):
     """SCIM Patch Request"""
+
     schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
     Operations: List[SCIMPatchOperation]
 
 
-def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) -> bool:
+def get_scim_auth(
+    request: Request, authorization: Optional[str] = Header(None)
+) -> bool:
     """
     Verify SCIM authentication
     Checks for SCIM-specific bearer token configured in the system
@@ -218,7 +239,7 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None))
             detail="Authorization header required",
             headers={"WWW-Authenticate": "Bearer"},
         )
-    
+
     try:
         parts = authorization.split()
         if len(parts) != 2:
@@ -226,19 +247,21 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None))
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Invalid authorization format. Expected: Bearer <token>",
             )
-        
+
         scheme, token = parts
         if scheme.lower() != "bearer":
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Invalid authentication scheme",
             )
-        
+
         # Check if SCIM is enabled
         scim_enabled = getattr(request.app.state.config, "SCIM_ENABLED", False)
-        log.info(f"SCIM auth check - raw SCIM_ENABLED: {scim_enabled}, type: {type(scim_enabled)}")
+        log.info(
+            f"SCIM auth check - raw SCIM_ENABLED: {scim_enabled}, type: {type(scim_enabled)}"
+        )
         # Handle both PersistentConfig and direct value
-        if hasattr(scim_enabled, 'value'):
+        if hasattr(scim_enabled, "value"):
             scim_enabled = scim_enabled.value
         log.info(f"SCIM enabled status after conversion: {scim_enabled}")
         if not scim_enabled:
@@ -246,11 +269,11 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None))
                 status_code=status.HTTP_403_FORBIDDEN,
                 detail="SCIM is not enabled",
             )
-        
+
         # Verify the SCIM token
         scim_token = getattr(request.app.state.config, "SCIM_TOKEN", None)
         # Handle both PersistentConfig and direct value
-        if hasattr(scim_token, 'value'):
+        if hasattr(scim_token, "value"):
             scim_token = scim_token.value
         log.debug(f"SCIM token configured: {bool(scim_token)}")
         if not scim_token or token != scim_token:
@@ -258,7 +281,7 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None))
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Invalid SCIM token",
             )
-        
+
         return True
     except HTTPException:
         # Re-raise HTTP exceptions as-is
@@ -266,6 +289,7 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None))
     except Exception as e:
         log.error(f"SCIM authentication error: {e}")
         import traceback
+
         log.error(f"Traceback: {traceback.format_exc()}")
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
@@ -273,14 +297,13 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None))
         )
 
 
-
 def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
     """Convert internal User model to SCIM User"""
     # Parse display name into name components
     name_parts = user.name.split(" ", 1) if user.name else ["", ""]
     given_name = name_parts[0] if name_parts else ""
     family_name = name_parts[1] if len(name_parts) > 1 else ""
-    
+
     # Get user's groups
     user_groups = Groups.get_groups_by_member_id(user.id)
     groups = [
@@ -288,11 +311,11 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
             "value": group.id,
             "display": group.name,
             "$ref": f"{request.base_url}api/v1/scim/v2/Groups/{group.id}",
-            "type": "direct"
+            "type": "direct",
         }
         for group in user_groups
     ]
-    
+
     return SCIMUser(
         id=user.id,
         userName=user.email,
@@ -304,12 +327,20 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
         displayName=user.name,
         emails=[SCIMEmail(value=user.email)],
         active=user.role != "pending",
-        photos=[SCIMPhoto(value=user.profile_image_url)] if user.profile_image_url else None,
+        photos=(
+            [SCIMPhoto(value=user.profile_image_url)]
+            if user.profile_image_url
+            else None
+        ),
         groups=groups if groups else None,
         meta=SCIMMeta(
             resourceType=SCIM_RESOURCE_TYPE_USER,
-            created=datetime.fromtimestamp(user.created_at, tz=timezone.utc).isoformat(),
-            lastModified=datetime.fromtimestamp(user.updated_at, tz=timezone.utc).isoformat(),
+            created=datetime.fromtimestamp(
+                user.created_at, tz=timezone.utc
+            ).isoformat(),
+            lastModified=datetime.fromtimestamp(
+                user.updated_at, tz=timezone.utc
+            ).isoformat(),
             location=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
         ),
     )
@@ -328,55 +359,43 @@ def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup:
                     display=user.name,
                 )
             )
-    
+
     return SCIMGroup(
         id=group.id,
         displayName=group.name,
         members=members,
         meta=SCIMMeta(
             resourceType=SCIM_RESOURCE_TYPE_GROUP,
-            created=datetime.fromtimestamp(group.created_at, tz=timezone.utc).isoformat(),
-            lastModified=datetime.fromtimestamp(group.updated_at, tz=timezone.utc).isoformat(),
+            created=datetime.fromtimestamp(
+                group.created_at, tz=timezone.utc
+            ).isoformat(),
+            lastModified=datetime.fromtimestamp(
+                group.updated_at, tz=timezone.utc
+            ).isoformat(),
             location=f"{request.base_url}api/v1/scim/v2/Groups/{group.id}",
         ),
     )
 
 
-
 # SCIM Service Provider Config
 @router.get("/ServiceProviderConfig")
 async def get_service_provider_config():
     """Get SCIM Service Provider Configuration"""
     return {
         "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
-        "patch": {
-            "supported": True
-        },
-        "bulk": {
-            "supported": False,
-            "maxOperations": 1000,
-            "maxPayloadSize": 1048576
-        },
-        "filter": {
-            "supported": True,
-            "maxResults": 200
-        },
-        "changePassword": {
-            "supported": False
-        },
-        "sort": {
-            "supported": False
-        },
-        "etag": {
-            "supported": False
-        },
+        "patch": {"supported": True},
+        "bulk": {"supported": False, "maxOperations": 1000, "maxPayloadSize": 1048576},
+        "filter": {"supported": True, "maxResults": 200},
+        "changePassword": {"supported": False},
+        "sort": {"supported": False},
+        "etag": {"supported": False},
         "authenticationSchemes": [
             {
                 "type": "oauthbearertoken",
                 "name": "OAuth Bearer Token",
-                "description": "Authentication using OAuth 2.0 Bearer Token"
+                "description": "Authentication using OAuth 2.0 Bearer Token",
             }
-        ]
+        ],
     }
 
 
@@ -393,8 +412,8 @@ async def get_resource_types(request: Request):
             "schema": SCIM_USER_SCHEMA,
             "meta": {
                 "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/User",
-                "resourceType": "ResourceType"
-            }
+                "resourceType": "ResourceType",
+            },
         },
         {
             "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
@@ -404,9 +423,9 @@ async def get_resource_types(request: Request):
             "schema": SCIM_GROUP_SCHEMA,
             "meta": {
                 "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/Group",
-                "resourceType": "ResourceType"
-            }
-        }
+                "resourceType": "ResourceType",
+            },
+        },
     ]
 
 
@@ -425,25 +444,17 @@ async def get_schemas():
                     "name": "userName",
                     "type": "string",
                     "required": True,
-                    "uniqueness": "server"
-                },
-                {
-                    "name": "displayName",
-                    "type": "string",
-                    "required": True
+                    "uniqueness": "server",
                 },
+                {"name": "displayName", "type": "string", "required": True},
                 {
                     "name": "emails",
                     "type": "complex",
                     "multiValued": True,
-                    "required": True
+                    "required": True,
                 },
-                {
-                    "name": "active",
-                    "type": "boolean",
-                    "required": False
-                }
-            ]
+                {"name": "active", "type": "boolean", "required": False},
+            ],
         },
         {
             "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
@@ -451,19 +462,15 @@ async def get_schemas():
             "name": "Group",
             "description": "Group",
             "attributes": [
-                {
-                    "name": "displayName",
-                    "type": "string",
-                    "required": True
-                },
+                {"name": "displayName", "type": "string", "required": True},
                 {
                     "name": "members",
                     "type": "complex",
                     "multiValued": True,
-                    "required": False
-                }
-            ]
-        }
+                    "required": False,
+                },
+            ],
+        },
     ]
 
 
@@ -479,7 +486,7 @@ async def get_users(
     """List SCIM Users"""
     skip = startIndex - 1
     limit = count
-    
+
     # Get users from database
     if filter:
         # Simple filter parsing - supports userName eq "email"
@@ -497,10 +504,10 @@ async def get_users(
         response = Users.get_users(skip=skip, limit=limit)
         users_list = response["users"]
         total = response["total"]
-    
+
     # Convert to SCIM format
     scim_users = [user_to_scim(user, request) for user in users_list]
-    
+
     return SCIMListResponse(
         totalResults=total,
         itemsPerPage=len(scim_users),
@@ -519,10 +526,9 @@ async def get_user(
     user = Users.get_user_by_id(user_id)
     if not user:
         return scim_error(
-            status_code=status.HTTP_404_NOT_FOUND,
-            detail=f"User {user_id} not found"
+            status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found"
         )
-    
+
     return user_to_scim(user, request)
 
 
@@ -540,11 +546,11 @@ async def create_user(
             status_code=status.HTTP_409_CONFLICT,
             detail=f"User with email {user_data.userName} already exists",
         )
-    
+
     # Create user
     user_id = str(uuid.uuid4())
     email = user_data.emails[0].value if user_data.emails else user_data.userName
-    
+
     # Parse name if provided
     name = user_data.displayName
     if user_data.name:
@@ -552,12 +558,12 @@ async def create_user(
             name = user_data.name.formatted
         elif user_data.name.givenName or user_data.name.familyName:
             name = f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip()
-    
+
     # Get profile image if provided
     profile_image = "/user.png"
     if user_data.photos and len(user_data.photos) > 0:
         profile_image = user_data.photos[0].value
-    
+
     # Create user
     new_user = Users.insert_new_user(
         id=user_id,
@@ -566,13 +572,13 @@ async def create_user(
         profile_image_url=profile_image,
         role="user" if user_data.active else "pending",
     )
-    
+
     if not new_user:
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             detail="Failed to create user",
         )
-    
+
     return user_to_scim(new_user, request)
 
 
@@ -590,30 +596,32 @@ async def update_user(
             status_code=status.HTTP_404_NOT_FOUND,
             detail=f"User {user_id} not found",
         )
-    
+
     # Build update dict
     update_data = {}
-    
+
     if user_data.userName:
         update_data["email"] = user_data.userName
-    
+
     if user_data.displayName:
         update_data["name"] = user_data.displayName
     elif user_data.name:
         if user_data.name.formatted:
             update_data["name"] = user_data.name.formatted
         elif user_data.name.givenName or user_data.name.familyName:
-            update_data["name"] = f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip()
-    
+            update_data["name"] = (
+                f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip()
+            )
+
     if user_data.emails and len(user_data.emails) > 0:
         update_data["email"] = user_data.emails[0].value
-    
+
     if user_data.active is not None:
         update_data["role"] = "user" if user_data.active else "pending"
-    
+
     if user_data.photos and len(user_data.photos) > 0:
         update_data["profile_image_url"] = user_data.photos[0].value
-    
+
     # Update user
     updated_user = Users.update_user_by_id(user_id, update_data)
     if not updated_user:
@@ -621,7 +629,7 @@ async def update_user(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             detail="Failed to update user",
         )
-    
+
     return user_to_scim(updated_user, request)
 
 
@@ -639,14 +647,14 @@ async def patch_user(
             status_code=status.HTTP_404_NOT_FOUND,
             detail=f"User {user_id} not found",
         )
-    
+
     update_data = {}
-    
+
     for operation in patch_data.Operations:
         op = operation.op.lower()
         path = operation.path
         value = operation.value
-        
+
         if op == "replace":
             if path == "active":
                 update_data["role"] = "user" if value else "pending"
@@ -658,7 +666,7 @@ async def patch_user(
                 update_data["email"] = value
             elif path == "name.formatted":
                 update_data["name"] = value
-    
+
     # Update user
     if update_data:
         updated_user = Users.update_user_by_id(user_id, update_data)
@@ -669,7 +677,7 @@ async def patch_user(
             )
     else:
         updated_user = user
-    
+
     return user_to_scim(updated_user, request)
 
 
@@ -686,14 +694,14 @@ async def delete_user(
             status_code=status.HTTP_404_NOT_FOUND,
             detail=f"User {user_id} not found",
         )
-    
+
     success = Users.delete_user_by_id(user_id)
     if not success:
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             detail="Failed to delete user",
         )
-    
+
     return None
 
 
@@ -709,16 +717,16 @@ async def get_groups(
     """List SCIM Groups"""
     # Get all groups
     groups_list = Groups.get_groups()
-    
+
     # Apply pagination
     total = len(groups_list)
     start = startIndex - 1
     end = start + count
     paginated_groups = groups_list[start:end]
-    
+
     # Convert to SCIM format
     scim_groups = [group_to_scim(group, request) for group in paginated_groups]
-    
+
     return SCIMListResponse(
         totalResults=total,
         itemsPerPage=len(scim_groups),
@@ -740,7 +748,7 @@ async def get_group(
             status_code=status.HTTP_404_NOT_FOUND,
             detail=f"Group {group_id} not found",
         )
-    
+
     return group_to_scim(group, request)
 
 
@@ -756,15 +764,15 @@ async def create_group(
     if group_data.members:
         for member in group_data.members:
             member_ids.append(member.value)
-    
+
     # Create group
     from open_webui.models.groups import GroupForm
-    
+
     form = GroupForm(
         name=group_data.displayName,
         description="",
     )
-    
+
     # Need to get the creating user's ID - we'll use the first admin
     admin_user = Users.get_super_admin_user()
     if not admin_user:
@@ -772,17 +780,18 @@ async def create_group(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             detail="No admin user found",
         )
-    
+
     new_group = Groups.insert_new_group(admin_user.id, form)
     if not new_group:
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             detail="Failed to create group",
         )
-    
+
     # Add members if provided
     if member_ids:
         from open_webui.models.groups import GroupUpdateForm
+
         update_form = GroupUpdateForm(
             name=new_group.name,
             description=new_group.description,
@@ -790,7 +799,7 @@ async def create_group(
         )
         Groups.update_group_by_id(new_group.id, update_form)
         new_group = Groups.get_group_by_id(new_group.id)
-    
+
     return group_to_scim(new_group, request)
 
 
@@ -808,20 +817,20 @@ async def update_group(
             status_code=status.HTTP_404_NOT_FOUND,
             detail=f"Group {group_id} not found",
         )
-    
+
     # Build update form
     from open_webui.models.groups import GroupUpdateForm
-    
+
     update_form = GroupUpdateForm(
         name=group_data.displayName if group_data.displayName else group.name,
         description=group.description,
     )
-    
+
     # Handle members if provided
     if group_data.members is not None:
         member_ids = [member.value for member in group_data.members]
         update_form.user_ids = member_ids
-    
+
     # Update group
     updated_group = Groups.update_group_by_id(group_id, update_form)
     if not updated_group:
@@ -829,7 +838,7 @@ async def update_group(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             detail="Failed to update group",
         )
-    
+
     return group_to_scim(updated_group, request)
 
 
@@ -847,20 +856,20 @@ async def patch_group(
             status_code=status.HTTP_404_NOT_FOUND,
             detail=f"Group {group_id} not found",
         )
-    
+
     from open_webui.models.groups import GroupUpdateForm
-    
+
     update_form = GroupUpdateForm(
         name=group.name,
         description=group.description,
         user_ids=group.user_ids.copy() if group.user_ids else [],
     )
-    
+
     for operation in patch_data.Operations:
         op = operation.op.lower()
         path = operation.path
         value = operation.value
-        
+
         if op == "replace":
             if path == "displayName":
                 update_form.name = value
@@ -881,7 +890,7 @@ async def patch_group(
                 member_id = path.split('"')[1]
                 if member_id in update_form.user_ids:
                     update_form.user_ids.remove(member_id)
-    
+
     # Update group
     updated_group = Groups.update_group_by_id(group_id, update_form)
     if not updated_group:
@@ -889,7 +898,7 @@ async def patch_group(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             detail="Failed to update group",
         )
-    
+
     return group_to_scim(updated_group, request)
 
 
@@ -906,12 +915,12 @@ async def delete_group(
             status_code=status.HTTP_404_NOT_FOUND,
             detail=f"Group {group_id} not found",
         )
-    
+
     success = Groups.delete_group_by_id(group_id)
     if not success:
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             detail="Failed to delete group",
         )
-    
-    return None
+
+    return None