Repository navigation
feat(proxy): let proxy admins choose which team fields team admins may edit #39996
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
9b77b5c
feat(proxy): let proxy admins choose which team fields team admins ma…
ryan-crabbe-berri ac47c95
fix(proxy): re-read UI settings on every config reload
ryan-crabbe-berri af0312b
refactor(ui): name the admin checks behind the team-admin edit branch
ryan-crabbe-berri 50f890d
fix(proxy): keep the team-admin field allow-list importable on python…
ryan-crabbe-berri 66519da
fix(proxy): report the caller's team edit access on /team/info
ryan-crabbe-berri d233043
test(proxy): expect team-admin budget changes to stop at the allow-list
ryan-crabbe-berri a44a58e
feat(proxy): let team admins edit tpm_limit when a proxy admin enable…
ryan-crabbe-berri 6e2ae19
fix(proxy): enforce org budget ceilings on /team/update
ryan-crabbe-berri b92bd98
feat(ui): save team admin editable fields from their own card with a …
ryan-crabbe-berri d9de2fc
style(ui): right-align the team admin editable fields Save button
ryan-crabbe-berri cac5212
style(ui): right-align the Save banner button
ryan-crabbe-berri c37d0a2
fix(proxy): read general_settings without a cast and test the /team/u…
ryan-crabbe-berri 36eb9cd
test(proxy): drop the route-list membership test that the PATCH gate …
ryan-crabbe-berri 81ae5ca
fix(proxy): pass only a team admin's changed fields on to the team up…
ryan-crabbe-berri ab92a66
test(e2e): cover team admin editable fields on /team/update
ryan-crabbe-berri 3ee8d43
fix(ui): send only a changed TPM limit from the team admin settings form
ryan-crabbe-berri File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
191 changes: 191 additions & 0 deletions
191
litellm/proxy/management_endpoints/team_admin_field_permissions.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,191 @@ | ||
| """Proxy-wide allow-list of team-settings fields a team admin may change on /team/update.""" | ||
|
|
||
| from collections.abc import Mapping | ||
| from dataclasses import dataclass | ||
| from types import MappingProxyType | ||
| from typing import Final, Literal, TypeAlias | ||
|
|
||
| from fastapi import HTTPException | ||
| from pydantic import TypeAdapter, ValidationError | ||
| from typing_extensions import assert_never | ||
|
|
||
| from litellm._logging import verbose_proxy_logger | ||
| from litellm.models.team import LiteLLM_TeamTable | ||
| from litellm.proxy._types import ( | ||
| LiteLLM_ManagementEndpoint_MetadataFields, | ||
| LiteLLM_ManagementEndpoint_MetadataFields_Premium, | ||
| UpdateTeamRequest, | ||
| ) | ||
|
|
||
| TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_fields" | ||
|
|
||
| # TODO(LIT-5722): add the remaining team settings one per PR, each with its value-diff tests and dashboard field | ||
| SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"tpm_limit"}) | ||
|
|
||
| _FIELD_LIST: Final = TypeAdapter(list[str]) | ||
| _JSON_OBJECT: Final = TypeAdapter(dict[str, object]) | ||
| _EMPTY: Final[Mapping[str, object]] = MappingProxyType({}) | ||
| _METADATA_FOLDED_FIELDS: Final[frozenset[str]] = frozenset( | ||
| (*LiteLLM_ManagementEndpoint_MetadataFields, *LiteLLM_ManagementEndpoint_MetadataFields_Premium) | ||
| ) | ||
| _SYSTEM_MANAGED_METADATA_KEYS: Final[frozenset[str]] = frozenset({"team_member_budget_id"}) | ||
| _NOT_COLUMNS: Final[frozenset[str]] = frozenset({"team_id", "metadata"}) | ||
| _SETTINGS_LOCATION: Final = "Settings > UI > Team admin editable fields" | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class TeamAdminEditAllowed: | ||
| request: UpdateTeamRequest | ||
| kind: Literal["allowed"] = "allowed" | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class TeamAdminEditingDisabled: | ||
| kind: Literal["disabled"] = "disabled" | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class TeamAdminFieldNotPermitted: | ||
| field: str | ||
| kind: Literal["field_not_permitted"] = "field_not_permitted" | ||
|
|
||
|
|
||
| TeamAdminEditVerdict: TypeAlias = TeamAdminEditAllowed | TeamAdminEditingDisabled | TeamAdminFieldNotPermitted | ||
|
|
||
|
|
||
| def resolve_team_admin_editable_fields( | ||
| general_settings: Mapping[str, object], | ||
| supported: frozenset[str], | ||
| ) -> frozenset[str]: | ||
| raw: Final = general_settings.get(TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING) | ||
| if raw is None: | ||
| return frozenset() | ||
| try: | ||
| configured: Final = frozenset(_FIELD_LIST.validate_python(raw)) | ||
| except ValidationError: | ||
| verbose_proxy_logger.warning( | ||
| "%s must be a list of field names; ignoring %r", TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, raw | ||
| ) | ||
| return frozenset() | ||
| unsupported: Final = configured - supported | ||
| if unsupported: | ||
| verbose_proxy_logger.warning( | ||
| "%s ignores unsupported field(s) %s; supported: %s", | ||
| TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, | ||
| sorted(unsupported), | ||
| sorted(supported), | ||
| ) | ||
| return configured & supported | ||
|
|
||
|
|
||
| def _as_object(value: object) -> Mapping[str, object]: | ||
| try: | ||
| return _JSON_OBJECT.validate_json(value) if isinstance(value, str) else _JSON_OBJECT.validate_python(value) | ||
| except ValidationError: | ||
| return _EMPTY | ||
|
|
||
|
|
||
| def _stored_metadata(existing: Mapping[str, object]) -> Mapping[str, object]: | ||
| return _as_object(existing.get("metadata")) | ||
|
|
||
|
|
||
| def _submitted_metadata( | ||
| data: UpdateTeamRequest, submitted: Mapping[str, object], existing: Mapping[str, object] | ||
| ) -> Mapping[str, object]: | ||
| """Metadata as it would be stored: the caller's dict (or the stored one) with top-level folded fields laid over.""" | ||
| base: Final = ( | ||
| _as_object(submitted.get("metadata")) if "metadata" in data.model_fields_set else _stored_metadata(existing) | ||
| ) | ||
| folded: Final = data.model_fields_set & _METADATA_FOLDED_FIELDS | ||
| return MappingProxyType({key: submitted[key] if key in folded else base[key] for key in base.keys() | folded}) | ||
|
|
||
|
|
||
| def _metadata_changes( | ||
| data: UpdateTeamRequest, submitted: Mapping[str, object], existing: Mapping[str, object] | ||
| ) -> frozenset[str]: | ||
| merged: Final = _submitted_metadata(data, submitted, existing) | ||
| stored: Final = _stored_metadata(existing) | ||
| return frozenset( | ||
| key if key in _METADATA_FOLDED_FIELDS else "metadata" | ||
| for key in (merged.keys() | stored.keys()) - _SYSTEM_MANAGED_METADATA_KEYS | ||
| if merged.get(key) != stored.get(key) | ||
| ) | ||
|
|
||
|
|
||
| def _stored_model_aliases(existing_row: LiteLLM_TeamTable) -> Mapping[str, object]: | ||
| table: Final = existing_row.litellm_model_table | ||
| return _as_object(_JSON_OBJECT.validate_json(table.model_dump_json()).get("model_aliases")) if table else _EMPTY | ||
|
|
||
|
|
||
| def _column_changed( | ||
| field: str, submitted: Mapping[str, object], existing: Mapping[str, object], existing_row: LiteLLM_TeamTable | ||
| ) -> bool: | ||
| if field == "model_aliases": | ||
| return _as_object(submitted.get(field)) != _stored_model_aliases(existing_row) | ||
| if field in LiteLLM_TeamTable.model_fields: | ||
| return submitted.get(field) != existing.get(field) | ||
| return True | ||
|
|
||
|
|
||
| def changed_team_fields(data: UpdateTeamRequest, existing_row: LiteLLM_TeamTable) -> frozenset[str]: | ||
| """Logical field names whose stored value the request would change. | ||
|
|
||
| Request and stored row are compared as JSON values so both sides share one representation. Fields the | ||
| server folds into metadata are attributed to their own name whether they arrive top-level or inside | ||
| ``metadata``; anything else in ``metadata`` is attributed to ``metadata``. Fields with no stored | ||
| counterpart on the team row count as changed whenever they are sent. | ||
| """ | ||
| submitted: Final = _JSON_OBJECT.validate_json(data.model_dump_json(exclude_unset=True)) | ||
| existing: Final = _JSON_OBJECT.validate_json(existing_row.model_dump_json()) | ||
| column_fields: Final = frozenset(data.model_fields_set) - _NOT_COLUMNS - _METADATA_FOLDED_FIELDS | ||
| column_changes: Final = frozenset( | ||
| field for field in column_fields if _column_changed(field, submitted, existing, existing_row) | ||
| ) | ||
| return column_changes | _metadata_changes(data, submitted, existing) | ||
|
|
||
|
|
||
| def _only_changes(data: UpdateTeamRequest, changed: frozenset[str]) -> UpdateTeamRequest: | ||
| """The request without the values it resends unchanged, which would otherwise still trigger derived writes | ||
| such as a resent budget_duration pushing budget_reset_at back.""" | ||
| sent: Final = frozenset(data.model_fields_set) | ||
| via_metadata: Final = frozenset({"metadata"}) if changed - sent else frozenset() | ||
| kept: Final = frozenset({"team_id"}) | (changed & sent) | via_metadata | ||
| return UpdateTeamRequest.model_validate(data.model_dump(include=MappingProxyType({field: True for field in kept}))) | ||
|
|
||
|
|
||
| def team_admin_edit_verdict( | ||
| data: UpdateTeamRequest, | ||
| existing: LiteLLM_TeamTable, | ||
| permitted: frozenset[str], | ||
| ) -> TeamAdminEditVerdict: | ||
| if not permitted: | ||
| return TeamAdminEditingDisabled() | ||
| changed: Final = changed_team_fields(data, existing) | ||
| blocked: Final = sorted(changed - permitted) | ||
| if blocked: | ||
| return TeamAdminFieldNotPermitted(field=blocked[0]) | ||
| return TeamAdminEditAllowed(request=_only_changes(data, changed)) | ||
|
|
||
|
|
||
| def team_admin_request_or_raise(verdict: TeamAdminEditVerdict) -> UpdateTeamRequest: | ||
|
|
||
| match verdict: | ||
| case TeamAdminEditAllowed(request=request): | ||
|
|
||
| return request | ||
| case TeamAdminEditingDisabled(): | ||
| raise HTTPException( | ||
| status_code=403, | ||
| detail=( | ||
| "Team admins on this proxy cannot edit team settings. " | ||
| f"Ask a proxy admin to enable fields under {_SETTINGS_LOCATION}." | ||
| ), | ||
| ) | ||
| case TeamAdminFieldNotPermitted(field=field): | ||
| raise HTTPException( | ||
| status_code=403, | ||
| detail=( | ||
| f"Team admins on this proxy do not have permission to update '{field}'. " | ||
| f"Ask a proxy admin to add it under {_SETTINGS_LOCATION}." | ||
| ), | ||
| ) | ||
| case _: | ||
| assert_never(verdict) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An unchanged team-admin request still writes to the database, refreshes caches, and creates avoidable audit noise
Knowledge Base Used: Proxy control plane and persistence
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 3ee8d43 on the client: the team admin form sends only changed values and keeps Save disabled until something changes