mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-11 21:46:22 +00:00
Merge pull request #15351 from BerriAI/litellm_dev_10_08_2025_p3
SSO - support EntraID app roles
This commit is contained in:
@@ -81,6 +81,23 @@ MICROSOFT_TENANT="5a39737
|
||||
http://localhost:4000/sso/callback
|
||||
```
|
||||
|
||||
**Using App Roles for User Permissions**
|
||||
|
||||
You can assign user roles directly from Entra ID using App Roles. LiteLLM will automatically read the app roles from the JWT token and assign the corresponding role to the user.
|
||||
|
||||
Supported roles:
|
||||
- `proxy_admin` - Admin over the platform
|
||||
- `proxy_admin_viewer` - Can login, view all keys, view all spend (read-only)
|
||||
- `internal_user` - Normal user. Can login, view spend and depending on team-member permissions - view/create/delete their own keys.
|
||||
|
||||
|
||||
To set up app roles:
|
||||
1. Navigate to your App Registration on https://portal.azure.com/
|
||||
2. Go to "App roles" and create a new app role
|
||||
3. Use one of the supported role names above (e.g., `proxy_admin`)
|
||||
4. Assign users to these roles in your Enterprise Application
|
||||
5. When users sign in via SSO, LiteLLM will automatically assign them the corresponding role
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="Generic" label="Generic SSO Provider">
|
||||
|
||||
@@ -140,6 +140,54 @@ litellm_settings:
|
||||
<Image img={require('../../img/msft_default_settings.png')} style={{ width: '900px', height: 'auto' }} />
|
||||
|
||||
|
||||
## 4. Using Entra ID App Roles for User Permissions
|
||||
|
||||
You can assign user roles directly from Entra ID using App Roles. LiteLLM will automatically read the app roles from the JWT token during SSO sign-in and assign the corresponding role to the user.
|
||||
|
||||
### 4.1 Supported Roles
|
||||
|
||||
LiteLLM supports the following app roles (case-insensitive):
|
||||
|
||||
- `proxy_admin` - Admin over the entire LiteLLM platform
|
||||
- `proxy_admin_viewer` - Read-only admin access (can view all keys and spend)
|
||||
- `org_admin` - Admin over a specific organization (can create teams and users within their org)
|
||||
- `internal_user` - Standard user (can create/view/delete their own keys and view their own spend)
|
||||
|
||||
### 4.2 Create App Roles in Entra ID
|
||||
|
||||
1. Navigate to your App Registration on https://portal.azure.com/
|
||||
2. Go to **App roles** > **Create app role**
|
||||
|
||||
3. Configure the app role:
|
||||
- **Display name**: Proxy Admin (or your preferred display name)
|
||||
- **Value**: `proxy_admin` (use one of the supported role values above)
|
||||
- **Description**: Administrator access to LiteLLM proxy
|
||||
- **Allowed member types**: Users/Groups
|
||||
|
||||
|
||||
4. Click **Apply** to save the role
|
||||
|
||||
### 4.3 Assign Users to App Roles
|
||||
|
||||
1. Navigate to **Enterprise Applications** on https://portal.azure.com/
|
||||
2. Select your LiteLLM application
|
||||
3. Go to **Users and groups** > **Add user/group**
|
||||
4. Select the user and assign them to one of the app roles you created
|
||||
|
||||
|
||||
### 4.4 Test the Role Assignment
|
||||
|
||||
1. Sign in to LiteLLM UI via SSO as a user with an assigned app role
|
||||
2. LiteLLM will automatically extract the app role from the JWT token
|
||||
3. The user will be assigned the corresponding LiteLLM role in the database
|
||||
4. The user's permissions will reflect their assigned role
|
||||
|
||||
**How it works:**
|
||||
- When a user signs in via Microsoft SSO, LiteLLM extracts the `roles` claim from the JWT `id_token`
|
||||
- If any of the roles match a valid LiteLLM role (case-insensitive), that role is assigned to the user
|
||||
- If multiple roles are present, LiteLLM uses the first valid role it finds
|
||||
- This role assignment persists in the LiteLLM database and determines the user's access level
|
||||
|
||||
## Video Walkthrough
|
||||
|
||||
This walks through setting up sso auto-add for **Microsoft Entra ID**
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,10 +4,48 @@ Types for the management endpoints
|
||||
Might include fastapi/proxy requirements.txt related imports
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from typing import List, Optional, cast
|
||||
|
||||
from fastapi_sso.sso.base import OpenID
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
|
||||
def is_valid_litellm_user_role(role_str: str) -> bool:
|
||||
"""
|
||||
Check if a string is a valid LitellmUserRoles enum value (case-insensitive).
|
||||
|
||||
Args:
|
||||
role_str: String to validate (e.g., "proxy_admin", "PROXY_ADMIN", "internal_user")
|
||||
|
||||
Returns:
|
||||
True if the string matches a valid LitellmUserRoles value, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Use _value2member_map_ for O(1) lookup, case-insensitive
|
||||
return role_str.lower() in LitellmUserRoles._value2member_map_
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_litellm_user_role(role_str: str) -> Optional[LitellmUserRoles]:
|
||||
"""
|
||||
Convert a string to a LitellmUserRoles enum if valid (case-insensitive).
|
||||
|
||||
Args:
|
||||
role_str: String to convert (e.g., "proxy_admin", "PROXY_ADMIN", "internal_user")
|
||||
|
||||
Returns:
|
||||
LitellmUserRoles enum if valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Use _value2member_map_ for O(1) lookup, case-insensitive
|
||||
result = LitellmUserRoles._value2member_map_.get(role_str.lower())
|
||||
return cast(Optional[LitellmUserRoles], result)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class CustomOpenID(OpenID):
|
||||
team_ids: List[str]
|
||||
user_role: Optional[LitellmUserRoles] = None
|
||||
|
||||
@@ -58,7 +58,7 @@ from litellm.proxy.management_endpoints.sso_helper_utils import (
|
||||
has_admin_ui_access,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team, team_member_add
|
||||
from litellm.proxy.management_endpoints.types import CustomOpenID
|
||||
from litellm.proxy.management_endpoints.types import CustomOpenID, get_litellm_user_role
|
||||
from litellm.proxy.utils import (
|
||||
PrismaClient,
|
||||
ProxyLogging,
|
||||
@@ -277,6 +277,7 @@ def generic_response_convertor(
|
||||
last_name=response.get(generic_user_last_name_attribute_name),
|
||||
provider=response.get(generic_provider_attribute_name),
|
||||
team_ids=all_teams,
|
||||
user_role=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -1145,7 +1146,7 @@ class SSOAuthenticationHandler:
|
||||
) -> str:
|
||||
"""
|
||||
Get the redirect URL for SSO
|
||||
|
||||
|
||||
Note: existing_key is not added to the URL to avoid changing the callback URL.
|
||||
It should be passed via the state parameter instead.
|
||||
"""
|
||||
@@ -1348,7 +1349,7 @@ class SSOAuthenticationHandler:
|
||||
Checks the request 'source' if a cli state token was passed in
|
||||
|
||||
This is used to authenticate through the CLI login flow.
|
||||
|
||||
|
||||
The state parameter format is: {PREFIX}:{key}:{existing_key}
|
||||
- If existing_key is provided, it's included in the state
|
||||
- The state parameter is used to pass data through the OAuth flow without changing the callback URL
|
||||
@@ -1673,22 +1674,49 @@ class MicrosoftSSOHandler:
|
||||
access_token=microsoft_sso.access_token
|
||||
)
|
||||
|
||||
# Extract app roles from the id_token JWT
|
||||
app_roles = MicrosoftSSOHandler.get_app_roles_from_id_token(
|
||||
id_token=microsoft_sso.id_token
|
||||
)
|
||||
verbose_proxy_logger.debug(f"Extracted app roles from id_token: {app_roles}")
|
||||
|
||||
# Combine groups and app roles
|
||||
user_role: Optional[LitellmUserRoles] = None
|
||||
if app_roles:
|
||||
# Check if any app role is a valid LitellmUserRoles
|
||||
for role_str in app_roles:
|
||||
role = get_litellm_user_role(role_str)
|
||||
if role is not None:
|
||||
user_role = role
|
||||
verbose_proxy_logger.debug(
|
||||
f"Found valid LitellmUserRoles '{role.value}' in app_roles"
|
||||
)
|
||||
break
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f"Combined team_ids (groups + app roles): {user_team_ids}"
|
||||
)
|
||||
|
||||
# if user is trying to get the raw sso response for debugging, return the raw sso response
|
||||
if return_raw_sso_response:
|
||||
original_msft_result[MicrosoftSSOHandler.GRAPH_API_RESPONSE_KEY] = (
|
||||
user_team_ids
|
||||
)
|
||||
original_msft_result["app_roles"] = app_roles
|
||||
return original_msft_result or {}
|
||||
|
||||
result = MicrosoftSSOHandler.openid_from_response(
|
||||
response=original_msft_result,
|
||||
team_ids=user_team_ids,
|
||||
user_role=user_role,
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def openid_from_response(
|
||||
response: Optional[dict], team_ids: List[str]
|
||||
response: Optional[dict],
|
||||
team_ids: List[str],
|
||||
user_role: Optional[LitellmUserRoles],
|
||||
) -> CustomOpenID:
|
||||
response = response or {}
|
||||
verbose_proxy_logger.debug(f"Microsoft SSO Callback Response: {response}")
|
||||
@@ -1700,10 +1728,54 @@ class MicrosoftSSOHandler:
|
||||
first_name=response.get("givenName"),
|
||||
last_name=response.get("surname"),
|
||||
team_ids=team_ids,
|
||||
user_role=user_role,
|
||||
)
|
||||
verbose_proxy_logger.debug(f"Microsoft SSO OpenID Response: {openid_response}")
|
||||
return openid_response
|
||||
|
||||
@staticmethod
|
||||
def get_app_roles_from_id_token(id_token: Optional[str]) -> List[str]:
|
||||
"""
|
||||
Extract app roles from the Microsoft Entra ID (Azure AD) id_token JWT.
|
||||
|
||||
App roles are assigned in the Azure AD Enterprise Application and appear
|
||||
in the 'roles' claim of the id_token.
|
||||
|
||||
Args:
|
||||
id_token (Optional[str]): The JWT id_token from Microsoft SSO
|
||||
|
||||
Returns:
|
||||
List[str]: List of app role names assigned to the user
|
||||
"""
|
||||
if not id_token:
|
||||
verbose_proxy_logger.debug("No id_token provided for app role extraction")
|
||||
return []
|
||||
|
||||
try:
|
||||
import jwt
|
||||
|
||||
# Decode the JWT without signature verification
|
||||
# (signature is already verified by fastapi_sso)
|
||||
decoded_token = jwt.decode(id_token, options={"verify_signature": False})
|
||||
|
||||
# Extract roles claim from the token
|
||||
roles = decoded_token.get("roles", [])
|
||||
|
||||
if roles and isinstance(roles, list):
|
||||
verbose_proxy_logger.debug(
|
||||
f"Found {len(roles)} app role(s) in id_token: {roles}"
|
||||
)
|
||||
return roles
|
||||
else:
|
||||
verbose_proxy_logger.debug(
|
||||
"No app roles found in id_token or roles claim is not a list"
|
||||
)
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"Error extracting app roles from id_token: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
async def get_user_groups_from_graph_api(
|
||||
access_token: Optional[str] = None,
|
||||
|
||||
Reference in New Issue
Block a user