Skip to content

feat(auth): resolve caller identity once into a Principal at the auth seam - #30887

Merged
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_resolve_identity_once
Jun 21, 2026
Merged

feat(auth): resolve caller identity once into a Principal at the auth seam#30887
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_resolve_identity_once

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Implements Phase 0 of the "Caller Identity: Resolve Once, Consume Everywhere" design (internal Notion). Supersedes the broad draft in #30171 by landing only the identity foundation, relocated under litellm/proxy/auth/ rather than a parallel auth_v2/.

Linear ticket

None

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

CI (LiteLLM team)

  • Branch creation CI run
    Link:
  • CI run for the last commit
    Link:
  • Merge / cherry-pick CI run
    Links:

Type

🆕 New Feature

Changes

This lands the caller-identity foundation: one typed, identity-only Principal resolved once at the auth seam and read by reference downstream, instead of identity being re-derived from the 50-field key object or rebuilt from request metadata strings.

New package litellm/proxy/auth/resolvers/, organized by responsibility:

  • Principal: a small frozen value type carrying user / organization / teams / project / end-user / roles / scopes / network, with its sub-models and role mapping. It holds no budget or policy state; those stay on the key object by design.
  • principal_from_key: projects a Principal off an already-resolved key object and issues no lookup, so identity is assembled the same way wherever it is read.
  • DbIdentityStore: the single chokepoint for key resolution. It owns the combined_view lookup via the cache-first get_key_object. user_api_key_auth now resolves keys through the store instead of calling get_key_object directly, so there is one place that does the lookup. The store returns the key object, which still flows unchanged for budget, rate-limit, and policy.

At the seam, user_api_key_auth projects one per-request Principal off the resolved key object and stamps the request network context onto it once; X-Forwarded-For is trusted only when trusted_proxy_ranges is configured, reusing the existing trusted_proxy_utils rather than a second parser. It is attached to request.state.principal for the consumers that later phases add. The projection is additive and defensive: a failure never rejects an already-authenticated request, and any future reader must treat a missing principal as deny. The Principal is always identifiable (a credential_ref and a stable subject are taken off the token), so it is never anonymous.

This is additive and changes no behavior today; it is the foundation the spend-attribution and authorization phases build on. Deliberately not included, to keep scope tight: the authenticators, RBAC/ABAC, SCIM, and session modules from the draft, and the downstream budget-object collapse in common_checks (that one is a separate, canary-gated change because those fetches carry budget state, not identity).

Note on the design's "remove redundant identity passes": verifying against a live proxy showed current litellm already resolves the team's org onto the token and already carries team_alias via combined_view, so the org / team-alias re-resolution the design targets is already handled upstream. There was no safe identity-only redundancy left to delete, so this PR does not add a no-op "fix" for it.

Screenshots / Proof of Fix

Run against a live proxy on localhost:4000 backed by Postgres, hitting the real Anthropic API. The build routes all key resolution through DbIdentityStore and projects a Principal at the seam.

Setup: an org, a team in that org, and a team-scoped key.

$ curl -s -X POST localhost:4000/organization/new -H "Authorization: Bearer sk-1234" \
    -H "Content-Type: application/json" -d '{"organization_alias":"acme-corp"}'
# organization_id = e3516fe9-7ea2-4ec4-9d82-1673ec3453c1

$ curl -s -X POST localhost:4000/team/new -H "Authorization: Bearer sk-1234" \
    -H "Content-Type: application/json" \
    -d '{"team_alias":"eng-team","organization_id":"e3516fe9-..."}'
# team_id = 61c158ee-ed45-473c-ae0c-2a0aae385996

$ curl -s -X POST localhost:4000/key/generate -H "Authorization: Bearer sk-1234" \
    -H "Content-Type: application/json" \
    -d '{"team_id":"61c158ee-...","models":["anthropic-haiku-4-5"]}'
# key = sk-...

Valid key resolves through the resolver and returns a real completion:

$ curl -s -X POST localhost:4000/v1/chat/completions -H "Authorization: Bearer sk-..." \
    -H "Content-Type: application/json" \
    -d '{"model":"anthropic-haiku-4-5","messages":[{"role":"user","content":"say hi in 3 words"}],"max_tokens":20}'
# resp: Hi there, friend.

Invalid key is rejected, master key still reaches admin routes:

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:4000/v1/chat/completions \
    -H "Authorization: Bearer sk-DEFINITELY-WRONG" -H "Content-Type: application/json" \
    -d '{"model":"anthropic-haiku-4-5","messages":[{"role":"user","content":"hi"}],"max_tokens":5}'
401

$ curl -s -o /dev/null -w "%{http_code}\n" "localhost:4000/key/info?key=sk-..." -H "Authorization: Bearer sk-1234"
200

Spend attribution is intact through the resolver path. The chat-completion rows carry the right ids:

$ psql -c "select coalesce(\"user\",'-'),team_id,organization_id,request_id
           from \"LiteLLM_SpendLogs\" order by \"startTime\" desc limit 2;"
 | 61c158ee-ed45-473c-ae0c-2a0aae385996 | e3516fe9-7ea2-4ec4-9d82-1673ec3453c1 | chatcmpl-8e58c273-...
 | 61c158ee-ed45-473c-ae0c-2a0aae385996 | e3516fe9-7ea2-4ec4-9d82-1673ec3453c1 | chatcmpl-5cd6898b-...

Unit coverage: the new tests/test_litellm/proxy/auth/test_resolvers_*.py cover the Principal model, the seam projection (identity off the key object, network stamping, non-anonymous credential_ref/subject), the XFF parser, and the store. The existing user_api_key_auth / auth_checks / handle_jwt suites stay green (344 passing), and the key-lookup mocks were repointed to the resolver's delegate.

Auth span comparison

Created a virtual key with access to openai model. Sent POST request. Auth span before and after should be the same.

Before changes

First chat completions
Screenshot 2026-06-20 at 5 15 23 PM
Second chat completions
Screenshot 2026-06-20 at 5 15 29 PM

After changes

First chat completions
Screenshot 2026-06-20 at 5 21 02 PM

Second chat completions
Screenshot 2026-06-20 at 5 21 15 PM

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

@codecov

codecov Bot commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.59664% with 20 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/auth/network.py 83.05% 10 Missing ⚠️
litellm/proxy/auth/resolvers/store.py 82.45% 10 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR lands Phase 0 of a "Caller Identity: Resolve Once" refactor: a new litellm/proxy/auth/resolvers/ package introduces a frozen Principal value type, an IdentityStore that centralizes key resolution through the existing cache-first helpers, and a seam in user_api_key_auth that stamps request.state.principal once per authenticated request. The change is fully additive — no existing auth behavior is altered, projection failures are non-fatal, and the 344 existing tests remain green.

  • Principal is a frozen Pydantic model carrying identity-only fields (user, org, teams, end-user, roles, scopes, network context); budget/rate-limit state stays on UserAPIKeyAuth by design, threaded through source_key as a transitional carrier.
  • Five get_key_object call sites in user_api_key_auth.py are replaced with inline IdentityStore construction; network.py extracts the XFF/CIDR logic from trusted_proxy_utils.py so the store can share it.
  • map_role in roles.py currently maps only 3 of the 7 LiteLLM user roles (proxy_admin, proxy_admin_viewer, org_admin); internal_user and other common roles produce empty Principal.roles, which future Phase 1 RBAC consumers will need to account for.

Confidence Score: 5/5

Safe to merge — the change is fully additive, projection failures are explicitly non-fatal, and the existing auth flow is structurally unchanged.

All call sites that previously called get_key_object now go through IdentityStore, which replicates the same cache-first, DB-fallback logic. The Principal stamping at the seam is wrapped in a broad except with warning-level logging, so no authenticated request can be rejected by the new code. The two findings are design-level observations for future phases, not defects in the current change.

roles.py — the incomplete map_role table is the most likely friction point when Phase 1 consumers start gating on Principal.roles.

Important Files Changed

Filename Overview
litellm/proxy/auth/resolvers/models.py New frozen Pydantic Principal value type; identity-only fields, no policy/budget state. source_key carrier is correctly excluded from serialization and repr.
litellm/proxy/auth/resolvers/store.py New IdentityStore with cache-first key resolution. Behavior mirrors original get_key_object (raises before cache when prisma_client=None). _principal_from_key is a private static used externally.
litellm/proxy/auth/resolvers/exceptions.py Typed exception hierarchy; KeyNotFoundError dual-inherits from IdentityResolutionError and ProxyException, preserving the existing 401 contract.
litellm/proxy/auth/user_api_key_auth.py Five get_key_object call sites replaced with inline IdentityStore construction; _resolve_request_principal stamps request.state.principal with warning-level logging on failure. Private static _principal_from_key called from module scope.
litellm/proxy/auth/roles.py New Role / TeamRole enums with map_role helper. Only 3 of 7 LiteLLM user roles are mapped; internal_user and others yield empty Principal.roles.
litellm/proxy/auth/network.py New NetworkContext / TrustedProxyConfig models and resolve_client_ip (right-to-left XFF walk). CIDR parsing happens per-call; already flagged in prior review threads.
litellm/proxy/auth/trusted_proxy_utils.py Refactored to delegate IP-range logic to network.py; get_trusted_proxy_cidrs helper extracted for use at the auth seam.
litellm/proxy/auth/auth_method.py New AuthMethod enum with 7 entries. Only API_KEY and BEARER_JWT are currently projected at the seam (per previous thread discussion).
tests/test_litellm/proxy/auth/test_resolvers_seam.py New unit tests for the auth seam: identity projection, non-anonymous credential_ref, XFF behavior, JWT detection. No real network calls.
tests/test_litellm/proxy/auth/test_resolvers_store.py Store tests with a _FakeCache stub; covers cache-hit projection, key_from_principal recovery, and no-DB error. Async tests run via asyncio_mode = "auto" in pyproject.toml.
tests/proxy_unit_tests/test_jwt_key_mapping.py Mocks repointed from get_key_object to IdentityStore._resolve_key. Race-condition test now only asserts called_once_with("winner_token_hash") — construction-time args (prisma_client, cache) no longer verified.
scripts/ruff_strict_gate.py Refactored: gather/report functions inlined into cmd_check; GateInputs named tuple removed; _temp_worktree context manager replaced with inline try/finally. Logic unchanged.

Reviews (4): Last reviewed commit: "feat(auth): resolve caller identity once..." | Re-trigger Greptile

Comment thread litellm/proxy/auth/user_api_key_auth.py
Comment thread litellm/proxy/auth/user_api_key_auth.py Outdated
Comment thread litellm/proxy/auth/user_api_key_auth.py
Comment thread litellm/proxy/auth/user_api_key_auth.py
@yassin-berriai
yassin-berriai force-pushed the litellm_resolve_identity_once branch from 8e876c7 to ffab361 Compare June 20, 2026 19:21
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai re-review please; updated HEAD: get_key_object resolution moved into DbIdentityStore, and the X-Forwarded-For/CIDR primitives consolidated into resolvers/network.py

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

… seam

Introduce a single, typed caller identity that is resolved once at the auth
boundary and read by reference downstream, instead of being re-derived from a
50-field key object or rebuilt from request metadata.

What this adds (litellm/proxy/auth/resolvers/), organized by responsibility:
- Principal: a small, frozen, identity-only value type (user / organization /
  teams / project / end-user / roles / scopes / network), with its sub-models
  and the role mapping. No budget or policy state; those stay on the key object.
- DbIdentityStore: the auth flow's resolver, owning both halves of resolving a
  caller. resolve_key does the one combined_view lookup (cache, then DB via the
  shared lower-level helpers, then write-back) and returns the key object, which
  still flows for budget / rate-limit / policy unchanged. principal_from_key
  projects the identity slice of that key object into a Principal, issuing no
  lookup. user_api_key_auth resolves every key through the store rather than
  calling get_key_object directly; auth_checks.get_key_object stays as the legacy
  entrypoint for its other callers until they migrate.
- network: the X-Forwarded-For / trusted-proxy CIDR primitives live here in one
  place. trusted_proxy_utils now imports them rather than keeping a second copy.

At the seam, user_api_key_auth projects one per-request Principal off the
resolved key object and stamps the request network context onto it once
(X-Forwarded-For is trusted only when trusted_proxy_ranges is configured). It is
attached to request.state.principal for the downstream consumers later phases
add. The projection is additive and defensive: a failure never rejects an
already-authenticated request, and a missing principal must be treated as deny
by any future reader. The Principal is always identifiable (credential_ref and a
stable subject off the token), never anonymous.

This is additive and changes no behavior today; it is the identity foundation
the spend-attribution and authorization phases build on.
@yassin-berriai
yassin-berriai force-pushed the litellm_resolve_identity_once branch from 09b5607 to 1639988 Compare June 21, 2026 01:16
@yassin-berriai
yassin-berriai enabled auto-merge (squash) June 21, 2026 01:17
@yassin-berriai
yassin-berriai merged commit 84266bf into litellm_internal_staging Jun 21, 2026
122 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_resolve_identity_once branch June 21, 2026 01:49
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
… seam (BerriAI#30887)

Introduce a single, typed caller identity that is resolved once at the auth
boundary and read by reference downstream, instead of being re-derived from a
50-field key object or rebuilt from request metadata.

What this adds (litellm/proxy/auth/resolvers/), organized by responsibility:
- Principal: a small, frozen, identity-only value type (user / organization /
  teams / project / end-user / roles / scopes / network), with its sub-models
  and the role mapping. No budget or policy state; those stay on the key object.
- DbIdentityStore: the auth flow's resolver, owning both halves of resolving a
  caller. resolve_key does the one combined_view lookup (cache, then DB via the
  shared lower-level helpers, then write-back) and returns the key object, which
  still flows for budget / rate-limit / policy unchanged. principal_from_key
  projects the identity slice of that key object into a Principal, issuing no
  lookup. user_api_key_auth resolves every key through the store rather than
  calling get_key_object directly; auth_checks.get_key_object stays as the legacy
  entrypoint for its other callers until they migrate.
- network: the X-Forwarded-For / trusted-proxy CIDR primitives live here in one
  place. trusted_proxy_utils now imports them rather than keeping a second copy.

At the seam, user_api_key_auth projects one per-request Principal off the
resolved key object and stamps the request network context onto it once
(X-Forwarded-For is trusted only when trusted_proxy_ranges is configured). It is
attached to request.state.principal for the downstream consumers later phases
add. The projection is additive and defensive: a failure never rejects an
already-authenticated request, and a missing principal must be treated as deny
by any future reader. The Principal is always identifiable (credential_ref and a
stable subject off the token), never anonymous.

This is additive and changes no behavior today; it is the identity foundation
the spend-attribution and authorization phases build on.
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.

4 participants