litellm/tests/test_litellm/proxy/auth/test_onboarding.py
Yassin Kortam 84266bf924
feat(auth): resolve caller identity once into a Principal at the auth seam (#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.
2026-06-20 18:49:41 -07:00

507 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Tests for the invite-link onboarding endpoints.
Covers the security behavior of:
GET /onboarding/get_token rejects already-used links and returns only a
short-lived onboarding token, not a UI session key
POST /onboarding/claim_token requires that onboarding token; mints the UI
session key only after the password is written
"""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
from fastapi import HTTPException
import litellm
from litellm.proxy._types import InvitationClaim
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _AsyncTx:
def __init__(self, db: MagicMock):
self.db = db
async def __aenter__(self) -> MagicMock:
return self.db
async def __aexit__(self, exc_type, exc, tb):
return False
def _make_invite(
*, is_accepted: bool, expired: bool = False, claimed: bool = False
) -> MagicMock:
now = litellm.utils.get_utc_datetime()
invite = MagicMock()
invite.id = "invite-abc"
invite.user_id = "user-123"
invite.is_accepted = is_accepted
invite.expires_at = now - timedelta(days=1) if expired else now + timedelta(days=6)
invite.accepted_at = now if claimed else None
return invite
def _make_user() -> MagicMock:
user = MagicMock()
user.user_id = "user-123"
user.user_email = "alice@example.com"
user.user_role = "internal_user"
return user
def _make_prisma(invite: MagicMock, user: MagicMock | None = None) -> MagicMock:
prisma = MagicMock()
prisma.db.litellm_invitationlink.find_unique = AsyncMock(return_value=invite)
prisma.db.litellm_invitationlink.update = AsyncMock()
prisma.db.litellm_invitationlink.update_many = AsyncMock(return_value=1)
prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=user)
prisma.db.litellm_usertable.update = AsyncMock(return_value=user)
prisma.db.tx = MagicMock(return_value=_AsyncTx(prisma.db))
return prisma
def _make_onboarding_token(
*,
invitation_link: str = "invite-abc",
user_id: str = "user-123",
token_type: str = "litellm_onboarding",
master_key: str = "sk-test",
) -> str:
return jwt.encode(
{
"token_type": token_type,
"invitation_link": invitation_link,
"user_id": user_id,
"exp": litellm.utils.get_utc_datetime() + timedelta(minutes=15),
},
master_key,
algorithm="HS256",
)
def _make_claim_request(token: str | None = None) -> MagicMock:
request = MagicMock()
request.headers = {"Authorization": f"Bearer {token}"} if token is not None else {}
request.base_url = "http://localhost:4000/"
return request
# ---------------------------------------------------------------------------
# GET /onboarding/get_token
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_token_rejects_already_used_link():
"""
If is_accepted is True the link was already claimed.
The endpoint must raise 401 *before* returning any user data.
"""
from litellm.proxy.proxy_server import onboarding
invite = _make_invite(is_accepted=True)
prisma = _make_prisma(invite)
request = MagicMock()
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
):
with pytest.raises(HTTPException) as exc_info:
await onboarding(invite_link="invite-abc", request=request)
assert exc_info.value.status_code == 401
assert "already been used" in exc_info.value.detail["error"]
# The user table must never have been queried
prisma.db.litellm_usertable.find_unique.assert_not_called()
@pytest.mark.asyncio
async def test_get_token_rejects_expired_link():
"""An expired link must raise 401 regardless of is_accepted."""
from litellm.proxy.proxy_server import onboarding
invite = _make_invite(is_accepted=False, expired=True)
prisma = _make_prisma(invite)
request = MagicMock()
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
):
with pytest.raises(HTTPException) as exc_info:
await onboarding(invite_link="invite-abc", request=request)
assert exc_info.value.status_code == 401
assert "expired" in exc_info.value.detail["error"]
@pytest.mark.asyncio
async def test_get_token_rejects_missing_link():
"""A link that does not exist in the DB must raise 401."""
from litellm.proxy.proxy_server import onboarding
prisma = _make_prisma(invite=None) # type: ignore[arg-type]
request = MagicMock()
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
):
with pytest.raises(HTTPException) as exc_info:
await onboarding(invite_link="nonexistent", request=request)
assert exc_info.value.status_code == 401
assert "does not exist" in exc_info.value.detail["error"]
@pytest.mark.asyncio
async def test_get_token_returns_onboarding_token_without_minting_ui_key():
"""
A valid, unused link should return a short-lived onboarding token, but
must not reserve the invite or mint a usable UI/API key on GET.
"""
from litellm.proxy.proxy_server import onboarding
invite = _make_invite(is_accepted=False)
user = _make_user()
prisma = _make_prisma(invite, user)
request = MagicMock()
request.base_url = "http://localhost:4000/"
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
patch("litellm.proxy.proxy_server.general_settings", {}),
patch("litellm.proxy.proxy_server.premium_user", False),
patch(
"litellm.proxy.proxy_server.generate_key_helper_fn",
new_callable=AsyncMock,
) as mock_generate_key,
patch(
"litellm.proxy.proxy_server.get_custom_url",
return_value="http://localhost:4000/",
),
patch(
"litellm.proxy.proxy_server.get_disabled_non_admin_personal_key_creation",
return_value=False,
),
patch("litellm.proxy.proxy_server.get_server_root_path", return_value=""),
):
result = await onboarding(invite_link="invite-abc", request=request)
# Endpoint succeeded
assert "token" in result
assert "login_url" in result
outer_claims = jwt.decode(result["token"], "sk-test", algorithms=["HS256"])
onboarding_token = outer_claims["key"]
onboarding_claims = jwt.decode(onboarding_token, "sk-test", algorithms=["HS256"])
assert onboarding_claims["token_type"] == "litellm_onboarding"
assert onboarding_claims["invitation_link"] == "invite-abc"
assert onboarding_claims["user_id"] == "user-123"
assert not onboarding_token.startswith("sk-")
mock_generate_key.assert_not_called()
prisma.db.litellm_invitationlink.update_many.assert_not_called()
prisma.db.litellm_invitationlink.update.assert_not_called()
# ---------------------------------------------------------------------------
# POST /onboarding/claim_token
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_claim_token_rejects_already_used_link():
"""
If is_accepted is True, the password has already been set.
A second claim attempt must be rejected with 401.
"""
from litellm.proxy.proxy_server import claim_onboarding_link
invite = _make_invite(is_accepted=True, claimed=True)
prisma = _make_prisma(invite)
data = InvitationClaim(
invitation_link="invite-abc",
user_id="user-123",
password="NewP@ssw0rd",
)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
with pytest.raises(HTTPException) as exc_info:
await claim_onboarding_link(data=data, request=_make_claim_request())
assert exc_info.value.status_code == 401
assert "already been used" in exc_info.value.detail["error"]
# Password must never have been written
prisma.db.litellm_usertable.update.assert_not_called()
@pytest.mark.asyncio
async def test_claim_token_rejects_expired_link():
"""An expired link must be rejected even if is_accepted is False."""
from litellm.proxy.proxy_server import claim_onboarding_link
invite = _make_invite(is_accepted=False, expired=True)
prisma = _make_prisma(invite)
data = InvitationClaim(
invitation_link="invite-abc",
user_id="user-123",
password="NewP@ssw0rd",
)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
with pytest.raises(HTTPException) as exc_info:
await claim_onboarding_link(data=data, request=_make_claim_request())
assert exc_info.value.status_code == 401
assert "expired" in exc_info.value.detail["error"]
@pytest.mark.asyncio
async def test_claim_token_rejects_mismatched_user_id():
"""The user_id in the request must match the one on the invite."""
from litellm.proxy.proxy_server import claim_onboarding_link
invite = _make_invite(is_accepted=False)
prisma = _make_prisma(invite)
data = InvitationClaim(
invitation_link="invite-abc",
user_id="wrong-user",
password="NewP@ssw0rd",
)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
with pytest.raises(HTTPException) as exc_info:
await claim_onboarding_link(data=data, request=_make_claim_request())
assert exc_info.value.status_code == 401
assert "does not match" in exc_info.value.detail["error"]
@pytest.mark.asyncio
async def test_claim_token_rejects_missing_onboarding_token():
"""The password endpoint must require the onboarding token returned by get_token."""
from litellm.proxy.proxy_server import claim_onboarding_link
invite = _make_invite(is_accepted=False)
prisma = _make_prisma(invite)
data = InvitationClaim(
invitation_link="invite-abc",
user_id="user-123",
password="NewP@ssw0rd",
)
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
patch("litellm.proxy.proxy_server.general_settings", {}),
):
with pytest.raises(HTTPException) as exc_info:
await claim_onboarding_link(data=data, request=_make_claim_request())
assert exc_info.value.status_code == 401
assert "Missing onboarding session" in exc_info.value.detail["error"]
prisma.db.litellm_usertable.update.assert_not_called()
@pytest.mark.asyncio
async def test_claim_token_rejects_wrong_onboarding_session():
"""The onboarding token must be bound to the invite and user being claimed."""
from litellm.proxy.proxy_server import claim_onboarding_link
invite = _make_invite(is_accepted=False)
prisma = _make_prisma(invite)
data = InvitationClaim(
invitation_link="invite-abc",
user_id="user-123",
password="NewP@ssw0rd",
)
request = _make_claim_request(
_make_onboarding_token(invitation_link="other-invite")
)
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
patch("litellm.proxy.proxy_server.general_settings", {}),
):
with pytest.raises(HTTPException) as exc_info:
await claim_onboarding_link(data=data, request=request)
assert exc_info.value.status_code == 401
assert "Invalid onboarding session" in exc_info.value.detail["error"]
prisma.db.litellm_usertable.update.assert_not_called()
@pytest.mark.asyncio
async def test_claim_token_rejects_invalid_bearer_token():
"""A regular API key must not be accepted as an onboarding token."""
from litellm.proxy.proxy_server import claim_onboarding_link
invite = _make_invite(is_accepted=False)
prisma = _make_prisma(invite)
data = InvitationClaim(
invitation_link="invite-abc",
user_id="user-123",
password="NewP@ssw0rd",
)
request = _make_claim_request("sk-regular-key")
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
patch("litellm.proxy.proxy_server.general_settings", {}),
):
with pytest.raises(HTTPException) as exc_info:
await claim_onboarding_link(data=data, request=request)
assert exc_info.value.status_code == 401
assert "Invalid onboarding session" in exc_info.value.detail["error"]
prisma.db.litellm_usertable.update.assert_not_called()
@pytest.mark.asyncio
async def test_claim_token_rejects_concurrent_reuse_before_password_write():
"""Only the first valid claim may reserve the invitation."""
from litellm.proxy.proxy_server import claim_onboarding_link
invite = _make_invite(is_accepted=False)
prisma = _make_prisma(invite)
prisma.db.litellm_invitationlink.update_many = AsyncMock(return_value=0)
request = _make_claim_request(_make_onboarding_token())
data = InvitationClaim(
invitation_link="invite-abc",
user_id="user-123",
password="NewP@ssw0rd",
)
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
patch("litellm.proxy.proxy_server.general_settings", {}),
patch(
"litellm.proxy.proxy_server.generate_key_helper_fn",
new_callable=AsyncMock,
) as mock_generate_key,
):
with pytest.raises(HTTPException) as exc_info:
await claim_onboarding_link(data=data, request=request)
assert exc_info.value.status_code == 401
assert "already been used" in exc_info.value.detail["error"]
prisma.db.litellm_usertable.update.assert_not_called()
mock_generate_key.assert_not_called()
@pytest.mark.asyncio
async def test_claim_token_sets_accepted_at_after_password_written():
"""
A valid first-time claim must:
1. Write the hashed password to the user table.
2. Set accepted_at on the invitation link after the password write succeeds.
"""
from litellm.proxy.proxy_server import claim_onboarding_link
invite = _make_invite(is_accepted=False)
user = _make_user()
prisma = _make_prisma(invite, user)
request = _make_claim_request(_make_onboarding_token())
data = InvitationClaim(
invitation_link="invite-abc",
user_id="user-123",
password="NewP@ssw0rd",
)
mock_token_response = {"token": "sk-generated-key", "user_id": "user-123"}
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
patch("litellm.proxy.proxy_server.general_settings", {}),
patch("litellm.proxy.proxy_server.premium_user", False),
patch(
"litellm.proxy.proxy_server.generate_key_helper_fn",
new_callable=AsyncMock,
return_value=mock_token_response,
),
patch(
"litellm.proxy.proxy_server.get_custom_url",
return_value="http://localhost:4000/",
),
patch(
"litellm.proxy.proxy_server.get_disabled_non_admin_personal_key_creation",
return_value=False,
),
patch("litellm.proxy.proxy_server.get_server_root_path", return_value=""),
):
result = await claim_onboarding_link(data=data, request=request)
# Password was written
prisma.db.litellm_invitationlink.update_many.assert_called_once()
reserve_kwargs = prisma.db.litellm_invitationlink.update_many.call_args.kwargs
assert reserve_kwargs["where"] == {"id": "invite-abc", "is_accepted": False}
assert reserve_kwargs["data"]["is_accepted"] is True
prisma.db.litellm_usertable.update.assert_called_once()
call_kwargs = prisma.db.litellm_usertable.update.call_args
assert call_kwargs.kwargs["where"] == {"user_id": "user-123"}
assert "password" in call_kwargs.kwargs["data"]
# is_accepted was flipped to True on the invitation link
prisma.db.litellm_invitationlink.update.assert_called_once()
link_update_data = prisma.db.litellm_invitationlink.update.call_args.kwargs["data"]
assert "is_accepted" not in link_update_data
assert link_update_data["accepted_at"] is not None
outer_claims = jwt.decode(result["token"], "sk-test", algorithms=["HS256"])
assert outer_claims["key"] == "sk-generated-key"
@pytest.mark.asyncio
async def test_claim_token_rolls_back_invite_when_session_key_mint_fails():
"""A session key failure must not leave the invite permanently consumed."""
from litellm.proxy.proxy_server import claim_onboarding_link
invite = _make_invite(is_accepted=False)
user = _make_user()
prisma = _make_prisma(invite, user)
request = _make_claim_request(_make_onboarding_token())
data = InvitationClaim(
invitation_link="invite-abc",
user_id="user-123",
password="NewP@ssw0rd",
)
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
patch("litellm.proxy.proxy_server.general_settings", {}),
patch(
"litellm.proxy.proxy_server.generate_key_helper_fn",
new_callable=AsyncMock,
side_effect=Exception("key mint failed"),
),
):
with pytest.raises(HTTPException) as exc_info:
await claim_onboarding_link(data=data, request=request)
assert exc_info.value.status_code == 500
assert "Failed to create onboarding session" in exc_info.value.detail["error"]
assert prisma.db.litellm_invitationlink.update_many.call_count == 2
rollback_kwargs = prisma.db.litellm_invitationlink.update_many.call_args_list[
1
].kwargs
assert rollback_kwargs["where"] == {
"id": "invite-abc",
"is_accepted": True,
}
assert rollback_kwargs["data"]["accepted_at"] is None
assert rollback_kwargs["data"]["is_accepted"] is False