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

  1. Remove the four protected fields from self-update payloads
  2. Ask a proxy admin (or use an admin key) to change budgets, spend, or object permissions
  3. 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

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


AI-assisted analysis of BerriAI/litellm@77b7c6c40c (2026-08-18). Data as JSON: /api/errors/4639a9304d490ea2. Report an issue: GitHub.