Skip to content

fix(proxy): align team member add with existing user provisioning rules - #35435

Merged
yuneng-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_team_member_mgmt_0731
Aug 1, 2026
Merged

fix(proxy): align team member add with existing user provisioning rules#35435
yuneng-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_team_member_mgmt_0731

Conversation

@yuneng-berri

Copy link
Copy Markdown
Collaborator

TLDR

Problem this solves:

  • /team/member_add created user rows for non-proxy-admins
  • Creating users directly is proxy-admin-only, so the two disagreed
  • /team/member_add wrote no audit log at all

How it solves it:

  • Only proxy admins may add an unused user_id
  • Email invites unchanged; the user_id is allocated server-side
  • Membership changes and created users now hit the audit log

Relevant issues

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Run against a live proxy on localhost:4001. The caller is a team admin whose proxy-level role is
internal_user; sk-1234 is the master key. Setup for both runs: create a team, create the
internal_user, make them a team admin of that team

Before, at f2cfa86713 (base of this branch). The team admin picks an arbitrary user_id and
the proxy creates a fully-roled user for it

curl -sX POST "$BASE/team/member_add" -H "Authorization: Bearer $TEAM_ADMIN_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"team_id":"proof-1785553306","member":{"user_id":"injected-proof-1785553306","role":"user"}}'
HTTP 200
members_with_roles: [{"user_id": "default_user_id", "user_email": null, "role": "admin"},
                     {"user_id": "ta-proof-1785553306", "user_email": null, "role": "admin"},
                     {"user_id": "injected-proof-1785553306", "user_email": null, "role": "user"}]
curl -s "$BASE/user/info?user_id=injected-proof-1785553306" -H "Authorization: Bearer sk-1234"
HTTP 200
user_role: internal_user | user_email: None

After, at e8e2e07ef6 (this branch). Same request, same caller

curl -sX POST "$BASE/team/member_add" -H "Authorization: Bearer $TEAM_ADMIN_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"team_id":"proof-1785553336","member":{"user_id":"injected-proof-1785553336","role":"user"}}'
HTTP 403
{"detail": {"error": "Only proxy admins can add a user_id that does not exist yet: injected-proof-1785553336. Add the member by user_email to invite a new user, or ask a proxy admin to create the user first."}}
curl -s "$BASE/user/info?user_id=injected-proof-1785553336" -H "Authorization: Bearer sk-1234"
HTTP 404
{"error": {"message": "User injected-proof-1785553336 not found", ... "code": "404"}}

Flows that stay open, also at e8e2e07ef6

# team admin invites a brand-new user by email
curl -sX POST "$BASE/team/member_add" -H "Authorization: Bearer $TEAM_ADMIN_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"team_id":"'"$TEAM"'","member":{"user_email":"newhire@example.com","role":"user"}}'   # HTTP 200

# team admin adds a user that already exists
curl -sX POST "$BASE/team/member_add" -H "Authorization: Bearer $TEAM_ADMIN_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"team_id":"'"$TEAM"'","member":{"user_id":"exists-1","role":"user"}}'                 # HTTP 200

# proxy admin pre-provisions a brand-new user_id
curl -sX POST "$BASE/team/member_add" -H "Authorization: Bearer sk-1234" \
  -H 'Content-Type: application/json' \
  -d '{"team_id":"'"$TEAM"'","member":{"user_id":"preprov-1","role":"user"}}'                # HTTP 200

Audit log, at e8e2e07ef6 (needs litellm_settings.store_audit_logs: true and a license). A
team admin invites a new user by email; before this change /audit returned nothing for either the
membership change or the created user

curl -s "$BASE/audit?object_id=$TEAM_ID&page_size=100" -H "Authorization: Bearer sk-1234"
# LiteLLM_TeamTable / updated, with members_with_roles before and after

curl -s "$BASE/audit?object_id=$NEW_USER_ID&page_size=100" -H "Authorization: Bearer sk-1234"
# LiteLLM_UserTable / created

Type

🐛 Bug Fix

Changes

_validate_member_user_id_provisioning rejects a user_id with no user row when the caller is not
a proxy admin, so choosing the identifier for a new account lines up with the restriction that
already applies to creating one directly. Team and org admins keep both of the paths they actually
use day to day: adding someone who already exists, and inviting someone new by user_email, where
the user_id is generated server-side rather than supplied by the caller

_create_team_member_add_audit_logs records the membership change on the team and a creation entry
for any user row the request adds, which brings this endpoint in line with /team/update,
/user/new and /key/*. The set of ids that already existed is captured before the members are
added, because the list-payload branch of _update_team_members_list back-fills the caller's own
Member objects with ids of users the same request just created; reading it afterwards would
classify a new user as pre-existing and skip its entry

Worth flagging for review: this is a behavior change for anyone pre-provisioning by a caller-chosen
user_id as a team or org admin rather than by user_email, who now gets a 403. The internal
callers were checked and are unaffected, since map_user_to_teams, create_team_member_add_task
and _add_user_to_team either pass a proxy-admin identity or run after the user row exists

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Adding a team member by a user_id with no user row created that row as a
side effect for any caller permitted to add members, while creating users
directly is restricted to proxy admins. Restrict that path to proxy admins
too; adding an existing user, and inviting a new one by user_email (where
the user_id is allocated server-side), are unchanged.

Also record the membership change, and any user row it creates, in the
audit log, matching /team/update, /user/new and /key/*.
@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR aligns team-member additions with existing user-provisioning permissions and adds audit records for membership and user creation.

  • Resolves caller-supplied user IDs in one bulk lookup before enforcing provisioning permissions.
  • Allows proxy administrators to provision new IDs while preserving email invitations and additions of existing users for team and organization administrators.
  • Records team membership changes and newly created users in the audit log.
  • Adds focused unit coverage for provisioning authorization, bulk lookup behavior, ID classification, and audit serialization.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/team_endpoints.py Adds bulk user-ID resolution, provisioning authorization, pre-existing-user classification, and audit logging to the team-member-add flow.
tests/proxy_unit_tests/test_proxy_server.py Updates the existing team-admin test to expect rejection when a non-proxy administrator supplies an unused user ID.
tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py Adds unit coverage for the new provisioning rules, bulk resolution, audit payload shape, and newly created-user classification.

Reviews (2): Last reviewed commit: "refactor(proxy): resolve team member loo..." | Re-trigger Greptile

Comment thread litellm/proxy/management_endpoints/team_endpoints.py Outdated
Comment thread litellm/proxy/management_endpoints/team_endpoints.py Outdated
@veria-ai

veria-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

PR overview

This PR updates the proxy team-management endpoint so adding team members follows the existing user provisioning rules.

One security issue remains open: when audit logging is enabled, a team or organization administrator can submit a very large member list and trigger unbounded concurrent audit inserts, potentially exhausting the shared database connection pool. One prior issue has been addressed, but member-count limits or bounded batching are still needed.

Open issues (1)

Fixed/addressed: 1 · PR risk: 4/10

@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_team_member_mgmt_0731 (48b3d18) with litellm_internal_staging (23de7a1)

Open in CodSpeed

… rejection message

Resolve the requested member user_ids with a single find_many instead of one
lookup per member, so a large member list no longer turns into that many
round-trips before the permission check runs. Write the member-add audit
entries concurrently rather than one after another, and list at most a few
ids in the rejection message instead of echoing the whole request back.

Update the team-admin member-add case that covered adding a user_id with no
user row, which the endpoint now leaves to proxy admins.
@yuneng-berri

Copy link
Copy Markdown
Collaborator Author

@greptile

@devin-ai-integration

Copy link
Copy Markdown
Contributor

QA verdict: PASS, with one product gap to decide on

Ran this branch against a live proxy on localhost:4000 with a real Postgres, plus a second proxy on localhost:4001 running the base commit f2cfa86713 against its own database, so before/after differ only by the tree. Roughly 60 curl requests across happy path, sad path and edge cases, then the Admin UI at http://localhost:4000/ui/ as a team admin and as a proxy admin

Proxy config used for the QA session

model_list:
  - model_name: anthropic-haiku-4-5
    litellm_params:
      model: anthropic/claude-haiku-4-5
      api_key: os.environ/ANTHROPIC_API_KEY

general_settings:
  master_key: sk-1234

litellm_settings:
  store_audit_logs: true
DATABASE_URL="postgresql://litellm:litellm@127.0.0.1:5433/litellm" LITELLM_MASTER_KEY=sk-1234 \
  uv run --no-sync python litellm/proxy/proxy_cli.py --config /home/ubuntu/qa35435/config.yaml --port 4000
The regression this PR closes, before and after, same request and same caller

Before, on f2cfa86713 (port 4001), the team admin conjures a user row out of an arbitrary id

curl -sX POST "$BASE/team/member_add" -H "Authorization: Bearer $TEAM_ADMIN_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"team_id":"3f87004a-...","member":{"user_id":"injected-b1","role":"user"}}'
# HTTP 200
curl -s "$BASE/user/info?user_id=injected-b1" -H "Authorization: Bearer sk-1234"
# HTTP 200, user_role: internal_user

After, on this branch (port 4000)

# HTTP 403
{"detail":{"error":"Only proxy admins can add a user_id that does not exist yet: injected-a1. Add the member by user_email to invite a new user, or ask a proxy admin to create the user first."}}
curl -s "$BASE/user/info?user_id=injected-a1" -H "Authorization: Bearer sk-1234"
# HTTP 404

The team's members_with_roles is byte-for-byte unchanged after the rejection, so nothing is partially applied

Happy paths that stay open (all 200)

Team admin adds an existing user by user_id; team admin invites a brand-new user by user_email and the row is created with a server-allocated id; team admin adds an existing user by user_email; proxy admin pre-provisions a brand-new user_id both with the master key and with a proxy_admin virtual key; team admin sends a list payload mixing an existing id and a new email; org admin adds an existing user to a team in their org

Sad paths

Team admin unknown id 403; org admin unknown id 403; list containing one unknown id is rejected as a whole with 403 and the known member from that same list is not partially added; a new user_email paired with a caller-chosen user_id is still 403, which is the case the description calls out; unknown team_id still 404 because the team lookup runs first; a plain internal user hits the existing permission error before the new gate; duplicate member 400; empty member list 400; email and id belonging to two different users 400; no auth 401

One expectation in the PR description reads slightly differently in practice: a member with neither user_id nor user_email returns 422 from pydantic's check_user_info validator, not the handler's 400, since validation happens before the endpoint body runs. Not a regression, the new check never sees that request

Edge cases, bulk behaviour and the batched lookup

Thirteen unknown ids in one request produce a message capped at ten with "and 3 more"; a case-differing email matches the existing row instead of creating a duplicate; twenty brand-new email invites in one call succeed and create twenty rows; thirty existing users added in one call succeed

The fan-out that the review bots flagged is genuinely fixed by 2a13bbe. One /team/member_add carrying forty existing members completed in 0.30 s, and Postgres logged exactly two batched IN (...) lookups against LiteLLM_UserTable for the resolution step rather than forty concurrent point queries

I could not test the ambiguous-email case where one address matches two user rows, because /user/new enforces email uniqueness with a 409, so the two rows cannot be seeded through the public API

Audit logging, with store_audit_logs: true

A brand-new user invited by a team admin gets a LiteLLM_UserTable / created entry attributed to the team admin, and the same request writes a LiteLLM_TeamTable / updated entry whose before and after values are the member lists either side of the change. Adding a user that already exists writes the membership entry but no creation entry, and that holds inside a list payload too: the newly created member is logged as created, the pre-existing one is not. A rejected request writes nothing at all. A proxy admin pre-provisioning an id gets the creation entry. On the base commit the same flows produced no membership entry and no creation entry

Small pre-existing quirk visible in the new payload: a member with no email serializes as the string "None" rather than null, because LiteLLM_AuditLogs.mask_api_keys runs the dict through SensitiveDataMasker, which stringifies nested values. It predates this PR and affects every audit row with nested nulls, but the new membership entries are the first place it shows up on this endpoint

Admin UI

Team admin adds an existing user by user id, member row appears and survives a reload

Add existing user

The 403 is hard to reach from the UI because the User ID field is a searchable select over existing users. The route that does reach it is a stale option: pick a real user, have a proxy admin delete that user, then submit. The gate fires with its exact message, the proxy logs 403, the member count is unchanged and no row is silently recreated

403 on an unknown user_id

Proxy admin is unaffected on the same team, and the Members tab, edit modal and member delete still behave

Proxy admin add

The one gap worth a decision before merge

Inviting a brand-new user by email works over the API, but it is not reachable from the Add Member modal. Both fields in UserSearchModal are antd Select showSearch with filterOption={false} and no tags mode, so typed text never becomes a form value: a fresh address shows "No data", and submitting posts an empty member, which the backend rejects with 422 behind a generic "Failed to add team member" toast

No data for a new email

That modal behaviour predates this PR. The interaction is what matters: with the arbitrary-user_id route now correctly closed, a team admin has no path in the UI at all for adding someone who does not yet have a user row. Worth deciding whether the modal should accept a free-text email, either here or in a fast follow

Tests and mutation check

tests/test_litellm/proxy/management_endpoints/ 2283 passed, and the touched tests/proxy_unit_tests/test_proxy_server.py -k team_member 8 passed. CI on the PR is green at 79 checks

The new tests earn their keep. Five mutations, each breaking one behaviour the PR claims, and the suite catches every one: making the provisioning guard a no-op fails 4 tests, classifying every member as pre-existing (so created users get no audit entry) fails 1, serializing the audit member list as a bare array fails 1, removing the ten-id cap fails 1, and emptying the batched id lookup fails 1

…ers only

Both fields select from a server-side search over existing accounts, so a
typed-in address or id never becomes a value. Say so up front rather than
letting the form look like it accepts a new user and fail on submit.

Applies to the organization member modal too, which shares this component.
@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor

@veria-ai re review

after_value=_members_audit_value(after_members),
)

await asyncio.gather(*created_user_entries, membership_entry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: Unbounded audit-log fan-out

A team or organization admin can submit an arbitrarily large list of new email members, causing this call to schedule one audit database insert per member concurrently. When audit logging is enabled, this can exhaust the shared connection pool; cap the request's member count or write these entries in bounded batches.

@yuneng-berri
yuneng-berri merged commit 20874f8 into litellm_internal_staging Aug 1, 2026
81 checks passed
@yuneng-berri
yuneng-berri deleted the litellm_team_member_mgmt_0731 branch August 1, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants