Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_endpoints/team_admin_field_permissions.py: 32%

162 statements  

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

1"""Proxy-wide allow-list of what a team admin may do on the teams they administer: team-settings fields on 

2/team/update, the ``projects`` permission for /project/new and /project/update, and the 

3``member_key_budgets`` permission for budget fields on other members' keys via /key/update.""" 

4 

5from collections.abc import Mapping 

6from dataclasses import dataclass 

7from types import MappingProxyType 

8from typing import Final, Literal, TypeAlias 

9 

10from fastapi import HTTPException 

11from pydantic import TypeAdapter, ValidationError 

12from typing_extensions import assert_never 

13 

14from litellm._logging import verbose_proxy_logger 

15from litellm.models.team import LiteLLM_TeamTable 

16from litellm.models.verification_token import LiteLLM_VerificationToken 

17from litellm.proxy._types import ( 

18 LiteLLM_ManagementEndpoint_MetadataFields, 

19 LiteLLM_ManagementEndpoint_MetadataFields_Premium, 

20 UpdateKeyRequest, 

21 UpdateTeamRequest, 

22) 

23 

24TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_fields" 

25 

26# TODO(LIT-5722): add the remaining team settings one per PR, each with its value-diff tests and dashboard field 

27SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"tpm_limit", "rpm_limit", "max_budget"}) 

28TEAM_ADMIN_PROJECTS_PERMISSION: Final = "projects" 

29TEAM_ADMIN_MEMBER_KEY_BUDGETS_PERMISSION: Final = "member_key_budgets" 

30SUPPORTED_TEAM_ADMIN_PERMISSIONS: Final[frozenset[str]] = SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS | { 

31 TEAM_ADMIN_PROJECTS_PERMISSION, 

32 TEAM_ADMIN_MEMBER_KEY_BUDGETS_PERMISSION, 

33} 

34 

35# spend is deliberately excluded: the stored row lags the live cross-pod counter, so a value-diff gate 

36# would let a team admin overwrite real usage. 

37KEY_BUDGET_FIELDS: Final[frozenset[str]] = frozenset({"max_budget", "budget_duration", "soft_budget", "budget_limits"}) 

38_KEY_REQUEST_IDENTITY: Final[frozenset[str]] = frozenset({"key", "token", "metadata"}) 

39 

40_FIELD_LIST: Final = TypeAdapter(list[str]) 

41_JSON_OBJECT: Final = TypeAdapter(dict[str, object]) 

42_WINDOW_LIST: Final = TypeAdapter(list[dict[str, object]]) 

43_EMPTY: Final[Mapping[str, object]] = MappingProxyType({}) 

44_METADATA_FOLDED_FIELDS: Final[frozenset[str]] = frozenset( 

45 (*LiteLLM_ManagementEndpoint_MetadataFields, *LiteLLM_ManagementEndpoint_MetadataFields_Premium) 

46) 

47_SYSTEM_MANAGED_METADATA_KEYS: Final[frozenset[str]] = frozenset({"team_member_budget_id"}) 

48_NOT_COLUMNS: Final[frozenset[str]] = frozenset({"team_id", "metadata"}) 

49_SETTINGS_LOCATION: Final = "Settings > UI > Team admin editable fields" 

50 

51 

52@dataclass(frozen=True, slots=True) 

53class TeamAdminEditAllowed: 

54 request: UpdateTeamRequest 

55 kind: Literal["allowed"] = "allowed" 

56 

57 

58@dataclass(frozen=True, slots=True) 

59class TeamAdminEditingDisabled: 

60 kind: Literal["disabled"] = "disabled" 

61 

62 

63@dataclass(frozen=True, slots=True) 

64class TeamAdminFieldNotPermitted: 

65 field: str 

66 kind: Literal["field_not_permitted"] = "field_not_permitted" 

67 

68 

69TeamAdminEditVerdict: TypeAlias = TeamAdminEditAllowed | TeamAdminEditingDisabled | TeamAdminFieldNotPermitted 

70 

71 

72def resolve_team_admin_editable_fields( 

73 general_settings: Mapping[str, object], 

74 supported: frozenset[str], 

75) -> frozenset[str]: 

76 raw: Final = general_settings.get(TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING) 

77 if raw is None: 

78 return frozenset() 

79 try: 

80 configured: Final = frozenset(_FIELD_LIST.validate_python(raw)) 

81 except ValidationError: 

82 verbose_proxy_logger.warning( 

83 "%s must be a list of field names; ignoring %r", TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, raw 

84 ) 

85 return frozenset() 

86 unsupported: Final = configured - supported - SUPPORTED_TEAM_ADMIN_PERMISSIONS 

87 if unsupported: 

88 verbose_proxy_logger.warning( 

89 "%s ignores unsupported field(s) %s; supported: %s", 

90 TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, 

91 sorted(unsupported), 

92 sorted(supported | SUPPORTED_TEAM_ADMIN_PERMISSIONS), 

93 ) 

94 return configured & supported 

95 

96 

97def team_admin_may_manage_projects(general_settings: Mapping[str, object]) -> bool: 

98 return TEAM_ADMIN_PROJECTS_PERMISSION in resolve_team_admin_editable_fields( 

99 general_settings, frozenset({TEAM_ADMIN_PROJECTS_PERMISSION}) 

100 ) 

101 

102 

103def team_admin_may_edit_member_key_budgets(general_settings: Mapping[str, object]) -> bool: 

104 return TEAM_ADMIN_MEMBER_KEY_BUDGETS_PERMISSION in resolve_team_admin_editable_fields( 

105 general_settings, frozenset({TEAM_ADMIN_MEMBER_KEY_BUDGETS_PERMISSION}) 

106 ) 

107 

108 

109def _as_object(value: object) -> Mapping[str, object]: 

110 try: 

111 return _JSON_OBJECT.validate_json(value) if isinstance(value, str) else _JSON_OBJECT.validate_python(value) 

112 except ValidationError: 

113 return _EMPTY 

114 

115 

116def _stored_metadata(existing: Mapping[str, object]) -> Mapping[str, object]: 

117 return _as_object(existing.get("metadata")) 

118 

119 

120def _submitted_metadata( 

121 data: UpdateTeamRequest | UpdateKeyRequest, submitted: Mapping[str, object], existing: Mapping[str, object] 

122) -> Mapping[str, object]: 

123 """Metadata as it would be stored: the caller's dict (or the stored one) with top-level folded fields laid over.""" 

124 base: Final = ( 

125 _as_object(submitted.get("metadata")) if "metadata" in data.model_fields_set else _stored_metadata(existing) 

126 ) 

127 folded: Final = data.model_fields_set & _METADATA_FOLDED_FIELDS 

128 return MappingProxyType({key: submitted[key] if key in folded else base[key] for key in base.keys() | folded}) 

129 

130 

131def _metadata_changes( 

132 data: UpdateTeamRequest | UpdateKeyRequest, submitted: Mapping[str, object], existing: Mapping[str, object] 

133) -> frozenset[str]: 

134 merged: Final = _submitted_metadata(data, submitted, existing) 

135 stored: Final = _stored_metadata(existing) 

136 return frozenset( 

137 key if key in _METADATA_FOLDED_FIELDS else "metadata" 

138 for key in (merged.keys() | stored.keys()) - _SYSTEM_MANAGED_METADATA_KEYS 

139 if merged.get(key) != stored.get(key) 

140 ) 

141 

142 

143def _stored_model_aliases(existing_row: LiteLLM_TeamTable) -> Mapping[str, object]: 

144 table: Final = existing_row.litellm_model_table 

145 return _as_object(_JSON_OBJECT.validate_json(table.model_dump_json()).get("model_aliases")) if table else _EMPTY 

146 

147 

148def _column_changed( 

149 field: str, submitted: Mapping[str, object], existing: Mapping[str, object], existing_row: LiteLLM_TeamTable 

150) -> bool: 

151 if field == "model_aliases": 

152 return _as_object(submitted.get(field)) != _stored_model_aliases(existing_row) 

153 if field in LiteLLM_TeamTable.model_fields: 

154 return submitted.get(field) != existing.get(field) 

155 return True 

156 

157 

158def changed_team_fields(data: UpdateTeamRequest, existing_row: LiteLLM_TeamTable) -> frozenset[str]: 

159 """Logical field names whose stored value the request would change. 

160 

161 Request and stored row are compared as JSON values so both sides share one representation. Fields the 

162 server folds into metadata are attributed to their own name whether they arrive top-level or inside 

163 ``metadata``; anything else in ``metadata`` is attributed to ``metadata``. Fields with no stored 

164 counterpart on the team row count as changed whenever they are sent. 

165 """ 

166 submitted: Final = _JSON_OBJECT.validate_json(data.model_dump_json(exclude_unset=True)) 

167 existing: Final = _JSON_OBJECT.validate_json(existing_row.model_dump_json()) 

168 column_fields: Final = frozenset(data.model_fields_set) - _NOT_COLUMNS - _METADATA_FOLDED_FIELDS 

169 column_changes: Final = frozenset( 

170 field for field in column_fields if _column_changed(field, submitted, existing, existing_row) 

171 ) 

172 return column_changes | _metadata_changes(data, submitted, existing) 

173 

174 

175def _only_changes(data: UpdateTeamRequest, changed: frozenset[str]) -> UpdateTeamRequest: 

176 """The request without the values it resends unchanged, which would otherwise still trigger derived writes 

177 such as a resent budget_duration pushing budget_reset_at back.""" 

178 sent: Final = frozenset(data.model_fields_set) 

179 via_metadata: Final = frozenset({"metadata"}) if changed - sent else frozenset[str]() 

180 kept: Final = frozenset({"team_id"}) | (changed & sent) | via_metadata 

181 return UpdateTeamRequest.model_validate(data.model_dump(include=MappingProxyType({field: True for field in kept}))) 

182 

183 

184def team_admin_edit_verdict( 

185 data: UpdateTeamRequest, 

186 existing: LiteLLM_TeamTable, 

187 permitted: frozenset[str], 

188) -> TeamAdminEditVerdict: 

189 if not permitted: 

190 return TeamAdminEditingDisabled() 

191 changed: Final = changed_team_fields(data, existing) 

192 blocked: Final = sorted(changed - permitted) 

193 if blocked: 

194 return TeamAdminFieldNotPermitted(field=blocked[0]) 

195 return TeamAdminEditAllowed(request=_only_changes(data, changed)) 

196 

197 

198def team_admin_request_or_raise(verdict: TeamAdminEditVerdict) -> UpdateTeamRequest: 

199 match verdict: 

200 case TeamAdminEditAllowed(): 

201 return verdict.request 

202 case TeamAdminEditingDisabled(): 

203 raise HTTPException( 

204 status_code=403, 

205 detail=( 

206 "Team admins on this proxy cannot edit team settings. " 

207 f"Ask a proxy admin to enable fields under {_SETTINGS_LOCATION}." 

208 ), 

209 ) 

210 case TeamAdminFieldNotPermitted(field=field): 

211 raise HTTPException( 

212 status_code=403, 

213 detail=( 

214 f"Team admins on this proxy do not have permission to update '{field}'. " 

215 f"Ask a proxy admin to add it under {_SETTINGS_LOCATION}." 

216 ), 

217 ) 

218 case _: 

219 assert_never(verdict) 

220 

221 

222def _budget_windows(value: object) -> frozenset[tuple[object, object]] | None: 

223 """(budget_duration, max_budget) pairs for a stored or submitted budget_limits value. 

224 

225 Stored windows carry server-added keys like ``reset_at``; only the caller-owned pair matters. 

226 ``None`` means the value is not a list of windows and needs a plain comparison. 

227 """ 

228 if value is None: 

229 return frozenset() 

230 if not isinstance(value, list): 

231 return None 

232 try: 

233 windows_input: Final = _WINDOW_LIST.validate_python(value) 

234 except ValidationError: 

235 return None 

236 windows: Final = frozenset((window.get("budget_duration"), window.get("max_budget")) for window in windows_input) 

237 if len(windows) != len(windows_input): 

238 return None 

239 return windows 

240 

241 

242def _key_column_changed(field: str, submitted: Mapping[str, object], existing: Mapping[str, object]) -> bool: 

243 if field == "budget_limits": 

244 sent: Final = _budget_windows(submitted.get(field)) 

245 stored: Final = _budget_windows(existing.get(field)) 

246 if sent is not None and stored is not None: 

247 return sent != stored 

248 if field in LiteLLM_VerificationToken.model_fields: 

249 return submitted.get(field) != existing.get(field) 

250 return True 

251 

252 

253def changed_key_fields(data: UpdateKeyRequest, existing_row: LiteLLM_VerificationToken) -> frozenset[str]: 

254 """Logical field names whose stored value the key-update request would change. 

255 

256 Same JSON-value comparison as :func:`changed_team_fields`: columns compare against the stored row, 

257 fields the key endpoint folds into ``metadata`` compare against ``existing_row.metadata``, other 

258 ``metadata`` keys are attributed to ``metadata``, and fields with no stored counterpart count as 

259 changed whenever they are sent. ``budget_limits`` compares (budget_duration, max_budget) pairs so 

260 order and server-computed ``reset_at`` values do not read as edits. 

261 """ 

262 submitted: Final = _JSON_OBJECT.validate_json(data.model_dump_json(exclude_unset=True)) 

263 existing: Final = _JSON_OBJECT.validate_json(existing_row.model_dump_json()) 

264 column_fields: Final = frozenset(data.model_fields_set) - _KEY_REQUEST_IDENTITY - _METADATA_FOLDED_FIELDS 

265 column_changes: Final = frozenset( 

266 field for field in column_fields if _key_column_changed(field, submitted, existing) 

267 ) 

268 return column_changes | _metadata_changes(data, submitted, existing) 

269 

270 

271@dataclass(frozen=True, slots=True) 

272class TeamAdminKeyEditAllowed: 

273 changed: frozenset[str] 

274 kind: Literal["allowed"] = "allowed" 

275 

276 

277@dataclass(frozen=True, slots=True) 

278class TeamAdminMemberKeyEditingDisabled: 

279 kind: Literal["disabled"] = "disabled" 

280 

281 

282TeamAdminKeyEditVerdict: TypeAlias = ( 

283 TeamAdminKeyEditAllowed | TeamAdminMemberKeyEditingDisabled | TeamAdminFieldNotPermitted 

284) 

285 

286 

287def team_admin_key_edit_verdict( 

288 data: UpdateKeyRequest, 

289 existing: LiteLLM_VerificationToken, 

290 enabled: bool, 

291) -> TeamAdminKeyEditVerdict: 

292 if not enabled: 

293 return TeamAdminMemberKeyEditingDisabled() 

294 changed: Final = changed_key_fields(data, existing) 

295 blocked: Final = sorted( 

296 (changed | (frozenset({"spend"}) if "spend" in data.model_fields_set else frozenset())) - KEY_BUDGET_FIELDS 

297 ) 

298 if blocked: 

299 return TeamAdminFieldNotPermitted(field=blocked[0]) 

300 return TeamAdminKeyEditAllowed(changed=changed) 

301 

302 

303def team_admin_key_request_or_raise(verdict: TeamAdminKeyEditVerdict) -> None: 

304 match verdict: 

305 case TeamAdminKeyEditAllowed(): 

306 return 

307 case TeamAdminMemberKeyEditingDisabled(): 

308 raise HTTPException( 

309 status_code=403, 

310 detail=( 

311 "Team admins on this proxy cannot update budgets on other members' keys. " 

312 f"Ask a proxy admin to enable '{TEAM_ADMIN_MEMBER_KEY_BUDGETS_PERMISSION}' " 

313 f"under {_SETTINGS_LOCATION}." 

314 ), 

315 ) 

316 case TeamAdminFieldNotPermitted(field=field): 

317 raise HTTPException( 

318 status_code=403, 

319 detail=( 

320 "Team admins on this proxy may only update budget fields on other members' keys, " 

321 f"not '{field}'. Ask a proxy admin to add it under {_SETTINGS_LOCATION}." 

322 ), 

323 ) 

324 case _: 

325 assert_never(verdict)