feat(proxy): restrict aws_session_tags on model management to proxy admins

Team admins can create and edit team models through /model/new, PUT /model/update
and PATCH /model/{id}/update. The proxy forwards aws_session_tags to STS under its
own identity, so a team admin could pick tags that unlock aws:PrincipalTag gated
resources. Only proxy admins may now set or change aws_session_tags there; an
unchanged tag set still passes so team admins can edit other fields
This commit is contained in:
ryan-crabbe-berri 2026-09-09 16:10:04 -07:00
parent d909c101ab
commit b1fb478e37
2 changed files with 223 additions and 0 deletions

View file

@ -111,6 +111,7 @@ from litellm.router_utils.auto_router_model_naming import (
validate_strategy_router_model_write,
)
from litellm.router_utils.auto_router_tuning_baseline import is_mutable_tuned_candidate, tuning_quota_violation
from litellm.types.llms.bedrock import AwsSessionTag
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
AutoRouterClassifierDefaultPromptResponse,
UpdateUsefulLinksRequest,
@ -897,6 +898,12 @@ async def patch_model(
existing_litellm_params=db_model.litellm_params,
)
ModelManagementAuthChecks.can_user_set_aws_session_tags(
litellm_params=patch_data.litellm_params,
user_api_key_dict=user_api_key_dict,
existing_litellm_params=db_model.litellm_params,
)
_raise_on_strategy_router_write_violation(
incoming_params=patch_data.litellm_params,
existing_params=db_model.litellm_params,
@ -1650,6 +1657,10 @@ async def _update_existing_team_model_assignment(
# No team_model_add/delete calls required; public name is already registered
def _canonical_session_tags(tags: Sequence[AwsSessionTag]) -> tuple[tuple[str, str], ...]:
return tuple(sorted((tag["Key"], tag["Value"]) for tag in tags))
class ModelManagementAuthChecks:
"""
Common auth checks for model management endpoints
@ -1704,6 +1715,28 @@ class ModelManagementAuthChecks:
param="litellm_credential_name",
)
@staticmethod
def can_user_set_aws_session_tags(
litellm_params: GenericLiteLLMParams | None,
user_api_key_dict: UserAPIKeyAuth,
existing_litellm_params: GenericLiteLLMParams | None = None,
) -> Literal[True]:
if litellm_params is None or litellm_params.aws_session_tags is None:
return True
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return True
existing_tags: Final = existing_litellm_params.aws_session_tags if existing_litellm_params is not None else None
if existing_tags is not None and _canonical_session_tags(existing_tags) == _canonical_session_tags(
litellm_params.aws_session_tags
):
return True
raise ProxyException(
message=f"Only a proxy admin can set aws_session_tags on a model. Your role={user_api_key_dict.user_role}.",
type=ProxyErrorTypes.auth_error.value,
code=status.HTTP_403_FORBIDDEN,
param="aws_session_tags",
)
@staticmethod
async def allow_team_model_action(
model_params: Deployment | updateDeployment,
@ -2037,6 +2070,11 @@ async def add_new_model(
user_api_key_dict=user_api_key_dict,
)
ModelManagementAuthChecks.can_user_set_aws_session_tags(
litellm_params=model_params.litellm_params,
user_api_key_dict=user_api_key_dict,
)
_raise_on_strategy_router_write_violation(
incoming_params=model_params.litellm_params,
existing_params=None,
@ -2221,6 +2259,12 @@ async def update_model(
existing_litellm_params=deployment.litellm_params,
)
ModelManagementAuthChecks.can_user_set_aws_session_tags(
litellm_params=model_params.litellm_params,
user_api_key_dict=user_api_key_dict,
existing_litellm_params=deployment.litellm_params,
)
_raise_on_strategy_router_write_violation(
incoming_params=model_params.litellm_params,
existing_params=deployment.litellm_params,

View file

@ -392,6 +392,185 @@ class TestModelManagementAuthChecks:
assert exc_info.value.code == "403"
mock_update.assert_not_awaited()
def test_can_user_set_aws_session_tags_admin_success(self):
result = ModelManagementAuthChecks.can_user_set_aws_session_tags(
litellm_params=LiteLLM_Params(
model="bedrock/test_model", aws_session_tags=[{"Key": "team", "Value": "genai"}]
),
user_api_key_dict=self.admin_user,
)
assert result is True
def test_can_user_set_aws_session_tags_without_tags_allows_any_role(self):
result = ModelManagementAuthChecks.can_user_set_aws_session_tags(
litellm_params=LiteLLM_Params(model="bedrock/test_model", aws_role_name="arn:aws:iam::123:role/x"),
user_api_key_dict=self.team_admin_user,
)
assert result is True
def test_can_user_set_aws_session_tags_team_admin_fails(self):
with pytest.raises(Exception, match="Only a proxy admin can set aws_session_tags") as exc_info:
ModelManagementAuthChecks.can_user_set_aws_session_tags(
litellm_params=LiteLLM_Params(
model="bedrock/test_model", aws_session_tags=[{"Key": "team", "Value": "genai"}]
),
user_api_key_dict=self.team_admin_user,
)
assert exc_info.value.code == "403"
assert exc_info.value.param == "aws_session_tags"
def test_can_user_set_aws_session_tags_unchanged_existing_allows_any_role(self):
result = ModelManagementAuthChecks.can_user_set_aws_session_tags(
litellm_params=LiteLLM_Params(
model="bedrock/test_model",
aws_session_tags=[{"Key": "team", "Value": "genai"}, {"Key": "env", "Value": "prod"}],
),
user_api_key_dict=self.team_admin_user,
existing_litellm_params=LiteLLM_Params(
model="bedrock/test_model",
aws_session_tags=[{"Key": "env", "Value": "prod"}, {"Key": "team", "Value": "genai"}],
),
)
assert result is True
def test_can_user_set_aws_session_tags_changed_value_fails_for_team_admin(self):
with pytest.raises(Exception, match="Only a proxy admin can set aws_session_tags") as exc_info:
ModelManagementAuthChecks.can_user_set_aws_session_tags(
litellm_params=LiteLLM_Params(
model="bedrock/test_model", aws_session_tags=[{"Key": "team", "Value": "platform"}]
),
user_api_key_dict=self.team_admin_user,
existing_litellm_params=LiteLLM_Params(
model="bedrock/test_model", aws_session_tags=[{"Key": "team", "Value": "genai"}]
),
)
assert exc_info.value.code == "403"
@pytest.mark.asyncio
async def test_add_new_model_rejects_aws_session_tags_for_non_admin(self):
from litellm.proxy._types import ProxyException
from litellm.proxy.management_endpoints.model_management_endpoints import (
add_new_model,
)
mock_prisma = MagicMock()
with (
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: endpoint reads proxy server globals with no injection seam
patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam
patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam
patch( # test-quality-ok: prior auth check needs a live DB; only the session tag check is under test
"litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call",
new=AsyncMock(return_value=None),
),
):
with pytest.raises(ProxyException) as exc_info:
await add_new_model(
model_params=Deployment(
model_name="tagged-bedrock",
litellm_params=LiteLLM_Params(
model="bedrock/anthropic.claude-opus-4-6-v1:0",
aws_role_name="arn:aws:iam::123456789012:role/team-role",
aws_session_tags=[{"Key": "team", "Value": "genai"}],
),
model_info={"id": "session-tags-create-test", "team_id": "test_team"},
),
user_api_key_dict=self.team_admin_user,
)
assert exc_info.value.code == "403"
assert exc_info.value.param == "aws_session_tags"
mock_prisma.db.litellm_proxymodeltable.create.assert_not_called()
@pytest.mark.asyncio
async def test_patch_model_rejects_aws_session_tags_for_non_admin(self):
from litellm.proxy._types import ProxyException
from litellm.proxy.management_endpoints.model_management_endpoints import (
patch_model,
)
from litellm.types.router import updateLiteLLMParams
model_id = "session-tags-patch-test"
db_model = Deployment(
model_name="tagged-bedrock",
litellm_params=LiteLLM_Params(
model="bedrock/anthropic.claude-opus-4-6-v1:0",
aws_role_name="arn:aws:iam::123456789012:role/team-role",
),
model_info={"id": model_id, "team_id": "test_team"},
)
with (
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: endpoint reads proxy server globals with no injection seam
patch("litellm.proxy.proxy_server.llm_router", MagicMock()), # test-quality-ok: endpoint reads proxy server globals with no injection seam
patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam
patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam
patch( # test-quality-ok: stubs the DB row fetch; only the session tag check is under test
"litellm.proxy.management_endpoints.model_management_endpoints.get_db_model",
new=AsyncMock(return_value=db_model),
),
patch( # test-quality-ok: prior auth check needs a live DB; only the session tag check is under test
"litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call",
new=AsyncMock(return_value=None),
),
patch( # test-quality-ok: asserts the DB write is never reached on rejection
"litellm.proxy.management_endpoints.model_management_endpoints._update_team_model_in_db",
new=AsyncMock(),
) as mock_update,
):
with pytest.raises(ProxyException) as exc_info:
await patch_model(
model_id=model_id,
patch_data=updateDeployment(
litellm_params=updateLiteLLMParams(aws_session_tags=[{"Key": "team", "Value": "genai"}])
),
user_api_key_dict=self.team_admin_user,
)
assert exc_info.value.code == "403"
assert exc_info.value.param == "aws_session_tags"
mock_update.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_model_rejects_aws_session_tags_for_non_admin(self):
from litellm.proxy._types import ProxyException
from litellm.proxy.management_endpoints.model_management_endpoints import (
update_model,
)
from litellm.types.router import updateLiteLLMParams
model_id = "session-tags-put-test"
existing = Deployment(
model_name="tagged-bedrock",
litellm_params=LiteLLM_Params(
model="bedrock/anthropic.claude-opus-4-6-v1:0",
aws_role_name="arn:aws:iam::123456789012:role/team-role",
),
model_info={"id": model_id, "team_id": "test_team"},
)
existing_row = MagicMock()
existing_row.model_dump.return_value = existing.model_dump()
mock_prisma = MagicMock()
mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=existing_row)
mock_prisma.db.litellm_proxymodeltable.update = AsyncMock()
with (
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: endpoint reads proxy server globals with no injection seam
patch("litellm.proxy.proxy_server.llm_router", None), # test-quality-ok: endpoint reads proxy server globals with no injection seam
patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam
patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam
patch( # test-quality-ok: prior auth check needs a live DB; only the session tag check is under test
"litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call",
new=AsyncMock(return_value=None),
),
):
with pytest.raises(ProxyException) as exc_info:
await update_model(
model_params=updateDeployment(
litellm_params=updateLiteLLMParams(aws_session_tags=[{"Key": "team", "Value": "genai"}]),
model_info=ModelInfo(id=model_id),
),
user_api_key_dict=self.team_admin_user,
)
assert exc_info.value.code == "403"
assert exc_info.value.param == "aws_session_tags"
mock_prisma.db.litellm_proxymodeltable.update.assert_not_awaited()
def test_can_user_attach_credential_internal_user_fails(self):
with pytest.raises(Exception, match="Only a proxy admin can attach a stored credential") as exc_info:
ModelManagementAuthChecks.can_user_attach_credential(