Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_endpoints/scim/scim_transformations.py: 21%

102 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1from collections.abc import Callable 

2from typing import Final, TypeVar 

3 

4from pydantic import ValidationError 

5 

6from litellm._logging import verbose_proxy_logger 

7from litellm.proxy._types import ( 

8 LiteLLM_TeamTable, 

9 LiteLLM_UserTable, 

10 Member, 

11 NewUserResponse, 

12) 

13from litellm.repositories.team_repository import TeamRepository 

14from litellm.types.proxy.management_endpoints.scim_v2 import * 

15 

16T = TypeVar("T") 

17 

18 

19class ScimTransformations: 

20 DEFAULT_SCIM_NAME = "Unknown User" 

21 DEFAULT_SCIM_FAMILY_NAME = "Unknown Family Name" 

22 DEFAULT_SCIM_DISPLAY_NAME = "Unknown Display Name" 

23 DEFAULT_SCIM_MEMBER_VALUE = "Unknown Member Value" 

24 

25 @staticmethod 

26 async def transform_litellm_user_to_scim_user( 

27 user: LiteLLM_UserTable | NewUserResponse, 

28 ) -> SCIMUser: 

29 from litellm.proxy.proxy_server import prisma_client 

30 

31 if prisma_client is None: 

32 raise HTTPException(status_code=500, detail={"error": "No database connected"}) 

33 

34 # Get user's teams/groups 

35 groups: Final = [] 

36 team_ids: Final[list[str]] = user.teams or [] # mutable-ok: scim reads the user row's team ids 

37 for team_id in team_ids: 

38 team = await TeamRepository(prisma_client).table.find_unique(where={"team_id": team_id}) 

39 if team: 

40 team_alias = getattr(team, "team_alias", team.team_id) 

41 groups.append(SCIMUserGroup(value=team.team_id, display=team_alias)) 

42 

43 user_created_at: Final = user.created_at.isoformat() if user.created_at else None 

44 user_updated_at: Final = user.updated_at.isoformat() if user.updated_at else None 

45 

46 emails: Final = [] 

47 # Only add email if it's a valid email address (contains @) 

48 # user_email can be a UUID when users are created without an email 

49 if user.user_email and "@" in user.user_email: 

50 emails.append(SCIMUserEmail(value=user.user_email, primary=True)) 

51 

52 metadata: Final = user.metadata or {} 

53 scim_active: Final = metadata.get("scim_active") 

54 active: Final = True if scim_active is None else bool(scim_active) 

55 

56 schemas: Final = ["urn:ietf:params:scim:schemas:core:2.0:User"] 

57 enterprise_user: Final = ScimTransformations._parse_directory_metadata( 

58 user, SCIM_ENTERPRISE_METADATA_KEY, SCIMEnterpriseUser.model_validate 

59 ) 

60 if enterprise_user is not None: 

61 schemas.append(SCIM_ENTERPRISE_USER_SCHEMA) 

62 

63 entitlements: Final = ScimTransformations._parse_directory_metadata( 

64 user, SCIM_ENTITLEMENTS_METADATA_KEY, SCIM_MULTI_VALUED_LIST_ADAPTER.validate_python 

65 ) 

66 roles: Final = ScimTransformations._parse_directory_metadata( 

67 user, SCIM_ROLES_METADATA_KEY, SCIM_MULTI_VALUED_LIST_ADAPTER.validate_python 

68 ) 

69 

70 return SCIMUser( 

71 schemas=schemas, 

72 id=user.user_id, 

73 userName=ScimTransformations._get_scim_user_name(user), 

74 displayName=ScimTransformations._get_scim_user_name(user), 

75 name=SCIMUserName( 

76 familyName=ScimTransformations._get_scim_family_name(user), 

77 givenName=ScimTransformations._get_scim_given_name(user), 

78 ), 

79 emails=emails, 

80 groups=groups, 

81 active=active, 

82 entitlements=entitlements, 

83 roles=roles, 

84 enterprise_user=enterprise_user, 

85 meta={ 

86 "resourceType": "User", 

87 "created": user_created_at, 

88 "lastModified": user_updated_at, 

89 }, 

90 ) 

91 

92 @staticmethod 

93 def _parse_directory_metadata( 

94 user: LiteLLM_UserTable | NewUserResponse, 

95 key: str, 

96 validate: Callable[[object], T], 

97 ) -> T | None: 

98 """A SCIM directory attribute parsed from user metadata, or None when absent or malformed. 

99 

100 Metadata is writable outside the SCIM surface, so a malformed value on one user must not 

101 fail the whole directory response; the attribute is omitted and the corruption logged. 

102 """ 

103 metadata: Final = user.metadata or {} 

104 raw: Final = metadata.get(key) 

105 if not raw: 

106 return None 

107 try: 

108 return validate(raw) 

109 except ValidationError: 

110 verbose_proxy_logger.warning( 

111 "Skipping malformed %s metadata on user %s in SCIM response", 

112 key, 

113 user.user_id, 

114 ) 

115 return None 

116 

117 @staticmethod 

118 def _get_scim_user_name(user: LiteLLM_UserTable | NewUserResponse) -> str: 

119 """ 

120 SCIM requires a display name with length > 0 

121 

122 We use the same userName and displayName for SCIM users 

123 """ 

124 if user.user_email and len(user.user_email) > 0: 

125 return user.user_email 

126 return ScimTransformations.DEFAULT_SCIM_DISPLAY_NAME 

127 

128 @staticmethod 

129 def _get_scim_family_name(user: LiteLLM_UserTable | NewUserResponse) -> str: 

130 """ 

131 SCIM requires a family name with length > 0 

132 """ 

133 metadata: Final = user.metadata or {} 

134 if "scim_metadata" in metadata: 

135 scim_metadata: Final[LiteLLM_UserScimMetadata] = LiteLLM_UserScimMetadata(**metadata["scim_metadata"]) 

136 if scim_metadata.familyName and len(scim_metadata.familyName) > 0: 

137 return scim_metadata.familyName 

138 

139 if user.user_alias and len(user.user_alias) > 0: 

140 return user.user_alias 

141 return ScimTransformations.DEFAULT_SCIM_FAMILY_NAME 

142 

143 @staticmethod 

144 def _get_scim_given_name(user: LiteLLM_UserTable | NewUserResponse) -> str: 

145 """ 

146 SCIM requires a given name with length > 0 

147 """ 

148 metadata: Final = user.metadata or {} 

149 if "scim_metadata" in metadata: 

150 scim_metadata: Final[LiteLLM_UserScimMetadata] = LiteLLM_UserScimMetadata(**metadata["scim_metadata"]) 

151 if scim_metadata.givenName and len(scim_metadata.givenName) > 0: 

152 return scim_metadata.givenName 

153 

154 if user.user_alias and len(user.user_alias) > 0: 

155 return user.user_alias or ScimTransformations.DEFAULT_SCIM_NAME 

156 return ScimTransformations.DEFAULT_SCIM_NAME 

157 

158 @staticmethod 

159 async def transform_litellm_team_to_scim_group( 

160 team: LiteLLM_TeamTable | dict, 

161 ) -> SCIMGroup: 

162 from litellm.proxy.proxy_server import prisma_client 

163 

164 if prisma_client is None: 

165 raise HTTPException(status_code=500, detail={"error": "No database connected"}) 

166 

167 if isinstance(team, dict): 

168 team = LiteLLM_TeamTable(**team) 

169 

170 # Get team members with proper display names 

171 scim_members: Final[list[SCIMMember]] = [] 

172 for member in team.members_with_roles or []: 

173 if isinstance(member, dict): 

174 member = Member(**member) 

175 

176 scim_members.append( 

177 SCIMMember( 

178 value=ScimTransformations._get_scim_member_value(member), 

179 display=ScimTransformations._get_scim_member_display(member), 

180 type="User", 

181 ) 

182 ) 

183 

184 team_alias: Final = getattr(team, "team_alias", team.team_id) 

185 team_created_at: Final = team.created_at.isoformat() if team.created_at else None 

186 team_updated_at: Final = team.updated_at.isoformat() if team.updated_at else None 

187 

188 return SCIMGroup( 

189 schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], 

190 id=team.team_id, 

191 displayName=team_alias, 

192 members=scim_members, 

193 meta={ 

194 "resourceType": "Group", 

195 "created": team_created_at, 

196 "lastModified": team_updated_at, 

197 }, 

198 ) 

199 

200 @staticmethod 

201 def _get_scim_member_value(member: Member) -> str: 

202 """The member's SCIM resource id, which LiteLLM serves as user_id (RFC 7643 §8.7.1).""" 

203 return member.user_id or ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE 

204 

205 @staticmethod 

206 def _get_scim_member_display(member: Member) -> str: 

207 """ 

208 Get the SCIM member display. Use user_email if available, otherwise use user_id. 

209 SCIM member display should be the display name for the user. 

210 """ 

211 if hasattr(member, "user_email") and member.user_email: 

212 return member.user_email 

213 elif hasattr(member, "user_id"): 

214 return member.user_id or ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE 

215 return ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE