mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-05 14:23:44 +00:00
fix(bedrock): grant aws-external-anthropic:* in OIDC session policy for claude_platform (#30200) (#30205)
The inline STS session policy passed to assume_role_with_web_identity acts as an IAM PERMISSION CEILING — effective permissions are the intersection of the role's identity policies and this policy. Any action not listed is silently denied even when the IAM role grants it. #27678 added the bedrock/claude_platform/<model> route but its service-side action namespace is aws-external-anthropic:*, not bedrock:*. Without a matching statement here, every claude_platform request via OIDC (GCP federation, EKS Pod Identity webhook, etc.) 403s with 'no session policy allows the aws-external-anthropic:CreateInference action' — even with a fully permissive identity policy. Add a second ClaudePlatformLiteLLM statement covering CreateInference, CreateBatchInference, CancelBatchInference, DeleteBatchInference, CountTokens, Get*, List*. Keep aws:SecureTransport=true parity with the bedrock statement. Static creds + IRSA flow through different code paths and are not affected. Fixes #30200
This commit is contained in:
@@ -861,14 +861,58 @@ class BaseAWSLLM:
|
||||
with tracer.trace("boto3.client(sts)"):
|
||||
sts_client = boto3.client("sts", **sts_client_kwargs)
|
||||
|
||||
# The session policy is an IAM PERMISSION CEILING — effective
|
||||
# permissions are the intersection of the role's identity policies
|
||||
# and this policy. Any action not listed here is silently denied
|
||||
# even when the IAM role grants it. So every Bedrock route we
|
||||
# support needs a matching action statement, or it 403s on OIDC
|
||||
# auth only (static creds + IRSA take other code paths).
|
||||
# https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html
|
||||
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sts/client/assume_role_with_web_identity.html
|
||||
bedrock_session_policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "BedrockLiteLLM",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"bedrock:InvokeModel",
|
||||
"bedrock:InvokeModelWithResponseStream",
|
||||
"bedrock:ApplyGuardrail",
|
||||
"bedrock:GetGuardrail",
|
||||
"bedrock:ListGuardrails",
|
||||
],
|
||||
"Resource": "*",
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "true"}},
|
||||
},
|
||||
# Claude Platform on AWS (added by #27678 for the
|
||||
# ``bedrock/claude_platform/<model>`` route) lives under
|
||||
# a separate IAM action namespace; without these entries
|
||||
# the OIDC path 403s on every claude_platform request
|
||||
# even with a fully permissive identity policy (#30200).
|
||||
{
|
||||
"Sid": "ClaudePlatformLiteLLM",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"aws-external-anthropic:CreateInference",
|
||||
"aws-external-anthropic:CreateBatchInference",
|
||||
"aws-external-anthropic:CancelBatchInference",
|
||||
"aws-external-anthropic:DeleteBatchInference",
|
||||
"aws-external-anthropic:CountTokens",
|
||||
"aws-external-anthropic:Get*",
|
||||
"aws-external-anthropic:List*",
|
||||
],
|
||||
"Resource": "*",
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "true"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
assume_role_params = {
|
||||
"RoleArn": aws_role_name,
|
||||
"RoleSessionName": aws_session_name,
|
||||
"WebIdentityToken": oidc_token,
|
||||
"DurationSeconds": 3600,
|
||||
"Policy": '{"Version":"2012-10-17","Statement":[{"Sid":"BedrockLiteLLM","Effect":"Allow","Action":["bedrock:InvokeModel","bedrock:InvokeModelWithResponseStream","bedrock:ApplyGuardrail","bedrock:GetGuardrail","bedrock:ListGuardrails"],"Resource":"*","Condition":{"Bool":{"aws:SecureTransport":"true"}}}]}',
|
||||
"Policy": json.dumps(bedrock_session_policy, separators=(",", ":")),
|
||||
}
|
||||
|
||||
# Add ExternalId parameter if provided
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Regression for #30200.
|
||||
|
||||
``_auth_with_web_identity_token`` passes an inline ``Policy`` to
|
||||
``sts.assume_role_with_web_identity``. In AWS IAM an STS session policy
|
||||
acts as a PERMISSION CEILING — effective permissions are the
|
||||
intersection of the role's identity policies and this policy, so any
|
||||
action not listed here 403s on OIDC-auth requests only (static creds
|
||||
and IRSA flow through different paths).
|
||||
|
||||
The original policy only granted ``bedrock:*`` actions. When
|
||||
``#27678`` added the ``bedrock/claude_platform/<model>`` route, the
|
||||
service-side action namespace was ``aws-external-anthropic:*``, not
|
||||
``bedrock:*``, so every claude_platform call via OIDC silently denied
|
||||
with::
|
||||
|
||||
User: arn:aws:sts::ACCOUNT:assumed-role/...
|
||||
is not authorized to perform: aws-external-anthropic:CreateInference
|
||||
on resource: arn:aws:aws-external-anthropic:...
|
||||
because no session policy allows the
|
||||
aws-external-anthropic:CreateInference action
|
||||
|
||||
— even with a fully permissive identity policy.
|
||||
|
||||
Tests below intercept the kwargs handed to
|
||||
``assume_role_with_web_identity``, parse the embedded ``Policy`` JSON,
|
||||
and assert that both the original bedrock statement and the new
|
||||
claude_platform statement are present and cover every documented
|
||||
action.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Actions the Claude Platform on AWS service is documented to call.
|
||||
# Source: AWS IAM action reference + the #27678 surface area.
|
||||
_CLAUDE_PLATFORM_ACTIONS = {
|
||||
"aws-external-anthropic:CreateInference",
|
||||
"aws-external-anthropic:CreateBatchInference",
|
||||
"aws-external-anthropic:CancelBatchInference",
|
||||
"aws-external-anthropic:DeleteBatchInference",
|
||||
"aws-external-anthropic:CountTokens",
|
||||
"aws-external-anthropic:Get*",
|
||||
"aws-external-anthropic:List*",
|
||||
}
|
||||
|
||||
|
||||
def _captured_policy() -> dict:
|
||||
"""Run _auth_with_web_identity_token under mocks + return the parsed
|
||||
Policy dict that was actually sent to STS."""
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
|
||||
base = BaseAWSLLM()
|
||||
|
||||
mock_sts = MagicMock()
|
||||
mock_sts.assume_role_with_web_identity.return_value = {
|
||||
"Credentials": {
|
||||
"AccessKeyId": "k",
|
||||
"SecretAccessKey": "s",
|
||||
"SessionToken": "t",
|
||||
"Expiration": datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
},
|
||||
"PackedPolicySize": 0,
|
||||
}
|
||||
|
||||
with (
|
||||
patch("boto3.client", return_value=mock_sts),
|
||||
patch(
|
||||
"litellm.llms.bedrock.base_aws_llm.get_secret",
|
||||
return_value="oidc-jwt-token",
|
||||
),
|
||||
):
|
||||
base._auth_with_web_identity_token(
|
||||
aws_web_identity_token="/path/to/token",
|
||||
aws_role_name="arn:aws:iam::123456789012:role/litellm-bedrock-role",
|
||||
aws_session_name="test-session",
|
||||
aws_region_name="us-east-1",
|
||||
aws_sts_endpoint=None,
|
||||
)
|
||||
|
||||
mock_sts.assume_role_with_web_identity.assert_called_once()
|
||||
kwargs = mock_sts.assume_role_with_web_identity.call_args.kwargs
|
||||
policy_str = kwargs["Policy"]
|
||||
return json.loads(policy_str)
|
||||
|
||||
|
||||
def _statement_by_sid(policy: dict, sid: str) -> dict:
|
||||
for stmt in policy["Statement"]:
|
||||
if stmt.get("Sid") == sid:
|
||||
return stmt
|
||||
raise AssertionError(
|
||||
f"Sid={sid!r} not found in session policy; "
|
||||
f"saw {[s.get('Sid') for s in policy['Statement']]}"
|
||||
)
|
||||
|
||||
|
||||
class TestWebIdentitySessionPolicyShape:
|
||||
def test_policy_parses_as_valid_iam_document(self):
|
||||
policy = _captured_policy()
|
||||
assert policy["Version"] == "2012-10-17"
|
||||
assert isinstance(policy["Statement"], list)
|
||||
assert len(policy["Statement"]) >= 2
|
||||
|
||||
def test_bedrock_statement_actions_preserved(self):
|
||||
"""The original bedrock action set must still be granted —
|
||||
regression for the pre-existing bedrock/* routes."""
|
||||
policy = _captured_policy()
|
||||
bedrock_stmt = _statement_by_sid(policy, "BedrockLiteLLM")
|
||||
actions = set(bedrock_stmt["Action"])
|
||||
for required in (
|
||||
"bedrock:InvokeModel",
|
||||
"bedrock:InvokeModelWithResponseStream",
|
||||
):
|
||||
assert required in actions, f"{required} missing from BedrockLiteLLM"
|
||||
|
||||
|
||||
class TestClaudePlatformActionsCovered:
|
||||
"""The #30200 bug: every action in the claude_platform service
|
||||
namespace must appear in the session policy or OIDC requests 403."""
|
||||
|
||||
@pytest.mark.parametrize("action", sorted(_CLAUDE_PLATFORM_ACTIONS))
|
||||
def test_claude_platform_action_present(self, action: str):
|
||||
policy = _captured_policy()
|
||||
# Action may live in any Statement — search across all.
|
||||
all_actions: set = set()
|
||||
for stmt in policy["Statement"]:
|
||||
stmt_actions = stmt.get("Action")
|
||||
if isinstance(stmt_actions, str):
|
||||
all_actions.add(stmt_actions)
|
||||
elif isinstance(stmt_actions, list):
|
||||
all_actions.update(stmt_actions)
|
||||
assert action in all_actions, (
|
||||
f"{action} missing from session policy — "
|
||||
f"bedrock/claude_platform/* requests will 403 on OIDC auth"
|
||||
)
|
||||
|
||||
def test_claude_platform_statement_allows(self):
|
||||
policy = _captured_policy()
|
||||
stmt = _statement_by_sid(policy, "ClaudePlatformLiteLLM")
|
||||
assert stmt["Effect"] == "Allow"
|
||||
assert stmt["Resource"] == "*"
|
||||
|
||||
def test_no_aws_external_anthropic_statement_collision(self):
|
||||
"""Don't accidentally grant a `*` action that would broaden the
|
||||
ceiling beyond what the documented actions require."""
|
||||
policy = _captured_policy()
|
||||
stmt = _statement_by_sid(policy, "ClaudePlatformLiteLLM")
|
||||
actions = stmt["Action"]
|
||||
if isinstance(actions, str):
|
||||
actions = [actions]
|
||||
assert "aws-external-anthropic:*" not in actions, (
|
||||
"session policy must not grant aws-external-anthropic:* — "
|
||||
"the ceiling should match the documented action set"
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyTransportConditions:
|
||||
def test_bedrock_statement_keeps_secure_transport_condition(self):
|
||||
policy = _captured_policy()
|
||||
bedrock_stmt = _statement_by_sid(policy, "BedrockLiteLLM")
|
||||
cond = bedrock_stmt.get("Condition") or {}
|
||||
assert cond.get("Bool", {}).get("aws:SecureTransport") == "true"
|
||||
|
||||
def test_claude_platform_statement_carries_secure_transport_condition(self):
|
||||
"""The new statement should match the existing one's hardening
|
||||
posture — TLS-only, same as bedrock."""
|
||||
policy = _captured_policy()
|
||||
stmt = _statement_by_sid(policy, "ClaudePlatformLiteLLM")
|
||||
cond = stmt.get("Condition") or {}
|
||||
assert cond.get("Bool", {}).get("aws:SecureTransport") == "true", (
|
||||
"ClaudePlatformLiteLLM must require aws:SecureTransport=true "
|
||||
"to keep parity with the bedrock statement"
|
||||
)
|
||||
Reference in New Issue
Block a user