BerriAI/litellm · error · HTTPException
f"Non-admin users cannot modify '{_field}' on their own reco
Error message
f"Non-admin users cannot modify '{_field}' on their own record. Contact your proxy admin." What it means
Self-update escalation guard on /user/update: when the caller's key user_id equals the target user and the caller is not proxy_admin, LiteLLM rejects writes to max_budget, soft_budget, spend, and object_permission with 403. object_permission is a ceiling an admin placed on the user, so sending object_permission: {} (empty) would lift that restriction - the check therefore inspects the fields the caller actually sent (fields_set), not the cleaned payload, and even an empty object_permission trips it.
Source
Thrown at litellm/proxy/management_endpoints/internal_user_endpoints.py:1398
# Prevent budget self-escalation (GHSA-wvg4-6222-3q4r): non-admin callers
# must not be able to raise their own budget/spend fields.
# can_user_call_user_update() already restricts non-admins to self-updates,
# so this guard only fires for self-escalation attempts.
_target_user_id: Final = user_request.user_id or (
getattr(existing_user_row, "user_id", None) if existing_user_row is not None else None
)
_is_self_update: Final = _target_user_id is not None and user_api_key_dict.user_id == _target_user_id
if _is_self_update and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
# object_permission is a CEILING on what this human may reach, so a self-write is an
# escalation path: sending an empty grant list means "no restriction" and would lift a
# restriction an admin placed on them. Checked against the fields the caller actually SENT,
# because `_update_internal_user_params` drops empty values, and `object_permission: {}` is
# precisely the clear-my-own-ceiling case this must refuse.
_sent_fields: Final = user_request.fields_set() if hasattr(user_request, "fields_set") else set()
_protected_fields: Final = ("max_budget", "soft_budget", "spend", "object_permission")
for _field in _protected_fields:
if _field in non_default_values or _field in _sent_fields:
raise HTTPException(
status_code=403,
detail={
"error": f"Non-admin users cannot modify '{_field}' on their own record. Contact your proxy admin."
},
)
existing_metadata: Final = (
cast(dict, getattr(existing_user_row, "metadata", {}) or {}) if existing_user_row is not None else {}
)
non_default_values = prepare_metadata_fields(
data=user_request,
non_default_values=non_default_values,
existing_metadata=existing_metadata or {},
)
# Reject NaN/±inf spend before it can reach the DB / spend counter.
validate_finite_spend(non_default_values.get("spend"))View on GitHub (pinned to 77b7c6c40c)
Solutions
- Remove the four protected fields from self-update payloads
- Ask a proxy admin (or use an admin key) to change budgets, spend, or object permissions
- If you keep the full user object client-side, strip server-owned fields before posting
Example fix
# before: self, non-admin
POST /user/update {"user_id": "me", "user_alias": "Me", "max_budget": 999} # 403
# after
POST /user/update {"user_id": "me", "user_alias": "Me"} # 200 Defensive patterns
Strategy: validation
Validate before calling
PROTECTED = {"max_budget", "soft_budget", "spend", "object_permission"}
def sanitize_self_update(payload: dict, key_role: str, key_user_id: str, target_user_id: str | None) -> dict:
if key_role != "proxy_admin" and target_user_id == key_user_id:
return {k: v for k, v in payload.items() if k not in PROTECTED}
return payload Type guard
type SelfSafeUpdate = Omit<Record<string, unknown>, "max_budget" | "soft_budget" | "spend" | "object_permission">;
function isSelfSafeUpdate(payload: Record<string, unknown>): boolean {
return !["max_budget", "soft_budget", "spend", "object_permission"].some(k => k in payload);
} Try / catch
except requests.HTTPError as e:
body = e.response.text if e.response is not None else ""
if e.response is not None and e.response.status_code == 403 and "own record" in body:
# strip protected fields and retry, or route the change to an admin
... Prevention
- Send only the fields the user actually changed, never the full user object
- Keep budget/spend/grant changes in an admin-only code path
- Treat object_permission as server-owned state; never echo it back from clients
When it happens
Trigger: Non-admin POST /user/update targeting their own user with any of max_budget, soft_budget, spend, or object_permission present in the body - including the empty-object case {"object_permission": {}} meant to 'clear' restrictions.
Common situations: Profile UIs that forward the entire user object back on save; users trying to raise their own budget or reset their spend to 0; clients syncing full state including server-managed grant lists.
Related errors
- f"Only proxy admins can create administrative users (proxy_a
- max_budget cannot be negative. Received: {data.max_budget}
- soft_budget cannot be negative. Received: {data.soft_budget}
- soft_budget ({data.soft_budget}) must be strictly lower than
- Project max_budget ({data.max_budget}) exceeds team's max_bu
AI-assisted analysis of BerriAI/litellm@77b7c6c40c (2026-08-18).
Data as JSON: /api/errors/4639a9304d490ea2.
Report an issue: GitHub.