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:
hcl
2026-06-12 16:29:14 +05:30
committed by GitHub
parent a00f7692ad
commit 4edf1ceb8c
2 changed files with 221 additions and 1 deletions
+45 -1
View File
@@ -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"
)