fix(bedrock): sign batch S3 requests with s3_access_key_id and s3_secret_access_key (#42342)

* fix(bedrock): sign batch S3 requests with s3_access_key_id and s3_secret_access_key

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(bedrock): keep S3 signer test additions scoped to new cases

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(bedrock): drop e2e suite changes from the S3 signing fix

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(bedrock): build S3 credentials directly from the s3_* pair so ambient AWS_* env never mixes in

Restores the split-identity e2e coverage

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yucheng <yucheng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-21 19:01:18 -07:00 • committed by GitHub
parent b720909dac
commit 5dc6261ebb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 257 additions and 7 deletions

View file

@ -46,6 +46,8 @@ OPTIONAL_KWARGS_KEYS: Final = (
"bucket_name",
"s3_endpoint_url",
"s3_region_name",
"s3_access_key_id",
"s3_secret_access_key",
"vertex_credentials",
"vertex_project",
"vertex_location",

View file

@ -524,6 +524,17 @@ class BaseAWSLLM(SignsRequestsWithAWS):
aws_session_tags=_canonical_aws_session_tags(auth_params.aws_session_tags),
)
def resolve_s3_credentials(self, params: Mapping[str, object], aws_region_name: str | None) -> Credentials:
"""S3 signing identity: the s3_* static pair as-is when both are set, otherwise the resolved aws_* params."""
from botocore.credentials import Credentials
from litellm.llms.bedrock.common_utils import s3_static_key_pair
s3_pair: Final = s3_static_key_pair(params)
if s3_pair is None:
return self.resolve_credentials(AwsAuthParams.model_validate(params), aws_region_name)
return Credentials(access_key=s3_pair[0], secret_key=s3_pair[1])
def _get_aws_region_from_model_arn(self, model: str | None) -> str | None:
try:
# First check if the string contains the expected prefix

View file

@ -112,6 +112,17 @@ def merge_bedrock_aws_request_params(
return request_params
def s3_static_key_pair(params: Mapping[str, object]) -> tuple[str, str] | None:
"""The s3_access_key_id / s3_secret_access_key pair when both are set, otherwise None."""
s3_access_key_id: Final = params.get("s3_access_key_id")
s3_secret_access_key: Final = params.get("s3_secret_access_key")
if not isinstance(s3_access_key_id, str) or not s3_access_key_id:
return None
if not isinstance(s3_secret_access_key, str) or not s3_secret_access_key:
return None
return s3_access_key_id, s3_secret_access_key
# Lazy import cache to avoid circular imports and performance impact
_get_model_info = None

View file

@ -11,7 +11,6 @@ from litellm.litellm_core_utils.cloud_storage_security import (
validate_managed_cloud_file_id,
)
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.bedrock import AwsAuthParams
from litellm.types.llms.openai import (
FileContentRequest,
HttpxBinaryResponseContent,
@ -103,9 +102,7 @@ class BedrockFilesHandler(BaseAWSLLM):
)
aws_region_name: Final = self._get_aws_region_name(optional_params=optional_params, model="")
credentials: Final[Credentials] = self.resolve_credentials(
AwsAuthParams.model_validate(optional_params), aws_region_name
)
credentials: Final[Credentials] = self.resolve_s3_credentials(optional_params, aws_region_name)
# Create S3 client
s3_client: Final = boto3.client(

View file

@ -63,7 +63,11 @@ from litellm.types.utils import ExtractedFileData, LlmProviders, SpecialEnums
from litellm.utils import get_llm_provider
from ..base_aws_llm import BaseAWSLLM
from ..common_utils import BedrockError, merge_bedrock_aws_request_params, resolve_s3_encryption_key_id
from ..common_utils import (
BedrockError,
merge_bedrock_aws_request_params,
resolve_s3_encryption_key_id,
)
S3_SIGNED_REQUEST_HEADERS_PARAM: Final = "_s3_signed_request_headers"
@ -148,6 +152,8 @@ class _BedrockS3RequestParams(AwsAuthParams):
aws_region_name: str | None = None
s3_region_name: str | None = None
s3_endpoint_url: str | None = None
s3_access_key_id: str | None = None
s3_secret_access_key: str | None = None
@dataclass(frozen=True, slots=True)
@ -1147,7 +1153,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.")
aws_region_name: Final = self._get_aws_region_name(optional_params=optional_params, model="")
credentials: Final = self.resolve_credentials(AwsAuthParams.model_validate(optional_params), aws_region_name)
credentials: Final = self.resolve_s3_credentials(optional_params, aws_region_name)
# Calculate SHA256 hash of the content (REQUIRED for S3)
content_hash: Final = hashlib.sha256(content.encode("utf-8")).hexdigest()
@ -1494,7 +1500,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
except ImportError:
raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.")
credentials: Final = self.resolve_credentials(request_params, aws_region_name)
credentials: Final = self.resolve_s3_credentials(request_params.model_dump(exclude_none=True), aws_region_name)
empty_body_hash: Final = hashlib.sha256(b"").hexdigest()
aws_request: Final = AWSRequest( # any-ok: botocore AWSRequest is untyped

View file

@ -21,6 +21,7 @@ failures are hard test failures (see `tests/e2e/AGENTS.md`).
| Vertex AI | yes | yes | yes | yes | yes (provider-transformed) | GCS (`gcs_bucket_name` / `GCS_BUCKET_NAME` on model) |
| Bedrock | yes (unified only) | yes | yes | yes (unfiltered managed list) | yes (provider-transformed) | S3 (`s3_bucket_name` + `aws_*` + `AWS_BATCH_ROLE_ARN` on model) |
| Bedrock GovCloud (`us-gov-west-1`) | yes (unified only) | yes | no | no | yes (provider-transformed) | S3 (`s3_bucket_name` + `aws_*` on model, resolved from `AWS_GOVCLOUD_ACCESS_KEY_ID` / `AWS_GOVCLOUD_SECRET_ACCESS_KEY` / `AWS_GOVCLOUD_BATCH_S3_BUCKET` / `AWS_GOVCLOUD_BATCH_ROLE_ARN`) |
| Bedrock split S3 identity | no | no | no | no | yes (file upload, content, delete) | S3 signed with `s3_access_key_id` / `s3_secret_access_key` (`AWS_S3_ONLY_ACCESS_KEY_ID` / `AWS_S3_ONLY_SECRET_ACCESS_KEY`, object rights on `AWS_BATCH_S3_BUCKET` only) while `aws_*` is `AWS_BEDROCK_ONLY_ACCESS_KEY_ID` / `AWS_BEDROCK_ONLY_SECRET_ACCESS_KEY`, an identity with no S3 rights on that bucket |
Bedrock cancel maps to `StopModelInvocationJob` and comes back `cancelling`; the
lifecycle asserts it the same way it does for OpenAI (`_CANCEL_ASSERTED_PROVIDERS`).

View file

@ -1024,6 +1024,72 @@ class TestBedrockBatchAssumeRole:
assert fetched.id == batch.id
def _split_s3_identity_params() -> LiteLLMParamsBody:
return LiteLLMParamsBody(
model=ASSUME_ROLE_RAW_MODEL,
aws_access_key_id="os.environ/AWS_BEDROCK_ONLY_ACCESS_KEY_ID",
aws_secret_access_key="os.environ/AWS_BEDROCK_ONLY_SECRET_ACCESS_KEY",
aws_region_name="os.environ/AWS_REGION",
s3_region_name="os.environ/AWS_REGION",
s3_bucket_name="os.environ/AWS_BATCH_S3_BUCKET",
s3_access_key_id="os.environ/AWS_S3_ONLY_ACCESS_KEY_ID",
s3_secret_access_key="os.environ/AWS_S3_ONLY_SECRET_ACCESS_KEY",
aws_batch_role_arn="os.environ/AWS_BATCH_ROLE_ARN",
)
class TestBedrockBatchSplitS3Credentials:
"""Bedrock batch deployment whose aws_* identity cannot touch the bucket.
AWS_BEDROCK_ONLY_* is an IAM user with no S3 rights on AWS_BATCH_S3_BUCKET;
AWS_S3_ONLY_* is an IAM user with object rights on that bucket only. Every
S3 call the proxy signs (PutObject on upload, GetObject on content,
DeleteObject on delete) must use the s3_* pair, otherwise S3 answers 403.
"""
@pytest.mark.covers(
"llm.files.bedrock.split_s3_credentials.nonstream.works",
exercised_on=["files"],
)
def test_file_lifecycle_signs_s3_with_s3_credentials(
self, client: BatchClient, resources: ResourceManager
) -> None:
model_name = batch_model_name("bedrock-split-s3-batch")
model_id = client.create_model(model_name, _split_s3_identity_params())
resources.defer(lambda: client.delete_model(model_id))
key = resources.key()
uploaded = client.upload_file(
content=render_jsonl(ASSUME_ROLE_RAW_MODEL),
form=FileUploadForm(purpose="batch", target_model_names=model_name),
key=key,
)
assert isinstance(uploaded, Success), (
f"upload must sign the S3 PutObject with s3_access_key_id, got {uploaded!r}"
)
file = uploaded.data
resources.defer(lambda: cleanup_file(client, file.id, key=key))
assert_file_object(file, provider="bedrock")
downloaded = client.proxy.transport.download(
f"/v1/files/{file.id}/content",
headers=client.proxy.transport.bearer(key),
)
assert downloaded.status_code == 200, (
f"content must sign the S3 GetObject with s3_access_key_id, "
f"got {downloaded.status_code}: {downloaded.body[:300]}"
)
assert all(json.loads(line) for line in downloaded.body.strip().splitlines()), (
f"content download returned non-JSONL body: {downloaded.body[:200]}"
)
deleted = client.delete_file(file.id, key=key)
assert isinstance(deleted, Success), (
f"delete must sign the S3 DeleteObject with s3_access_key_id, got {deleted!r}"
)
assert deleted.data.id == file.id, f"delete confirmed a different file: {deleted.data!r}"
GOVCLOUD_REGION: Final = "us-gov-west-1"
GOVCLOUD_RAW_MODEL: Final = "bedrock/amazon.nova-lite-v1:0"

View file

@ -47,6 +47,7 @@
- {id: llm.files.vertex.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:52", rationale: "Vertex file upload to GCS"}
- {id: llm.files.bedrock.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:59", rationale: "Bedrock file upload to S3"}
- {id: llm.files.bedrock.govcloud_partition.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: bedrock_converse, capability: govcloud_partition, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Bedrock file upload to an S3 bucket in the us-gov-west-1 partition"}
- {id: llm.files.bedrock.split_s3_credentials.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: bedrock_converse, capability: split_s3_credentials, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py / LIT-8297", rationale: "Bedrock file upload, content and delete sign S3 with s3_access_key_id / s3_secret_access_key when they differ from the aws_* identity"}
- {id: llm.files.gemini.upload.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: gemini, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Gemini Files API upload via proxy"}
- {id: llm.files.hosted_vllm.upload.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: hosted_vllm, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "hosted_vllm OpenAI-compatible file upload"}
- {id: llm.files.openai.require_managed_files_upload.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "test_managed_files_enforcement_e2e.py / LIT-5902", rationale: "With require_managed_files enabled, an upload without target_model_names and an upload carrying a model param are both rejected 400; runs only in the sequential managed-files stack phase (E2E_MANAGED_FILES_STACK)"}

View file

@ -67,6 +67,7 @@ LlmCapability = Literal[
"batch_deployment",
"count_tokens",
"govcloud_partition",
"split_s3_credentials",
"input_validation",
"long_context_1m",
"mid_conversation_system",

View file

@ -67,6 +67,15 @@ class TestGetLitellmParamsKwargsExtraction:
assert "s3_endpoint_url" not in result_without_s3_kwargs
assert "s3_region_name" not in result_without_s3_kwargs
def test_s3_credential_kwargs_are_forwarded_for_s3_signing(self):
result = get_litellm_params(s3_access_key_id="s3-key", s3_secret_access_key="s3-secret")
assert result["s3_access_key_id"] == "s3-key"
assert result["s3_secret_access_key"] == "s3-secret"
result_without_s3_kwargs = get_litellm_params()
assert "s3_access_key_id" not in result_without_s3_kwargs
assert "s3_secret_access_key" not in result_without_s3_kwargs
def test_subset_of_kwargs_only_includes_provided(self):
"""Only provided kwargs appear, others remain absent."""
result = get_litellm_params(azure_ad_token="token123")

View file

@ -926,3 +926,31 @@ def test_every_bedrock_config_get_error_class_keeps_provider_headers(config):
def test_bedrock_get_error_class_audit_covers_every_surface():
assert len(_bedrock_configs_with_get_error_class()) >= 30
def test_s3_static_key_pair_returns_the_pair_when_both_keys_are_set():
from litellm.llms.bedrock.common_utils import s3_static_key_pair
assert s3_static_key_pair(
{
"aws_access_key_id": "bedrock-key",
"aws_secret_access_key": "bedrock-secret",
"s3_access_key_id": "s3-key",
"s3_secret_access_key": "s3-secret",
}
) == ("s3-key", "s3-secret")
@pytest.mark.parametrize(
"partial_s3_pair",
[
{},
{"s3_access_key_id": "s3-key"},
{"s3_secret_access_key": "s3-secret"},
{"s3_access_key_id": "", "s3_secret_access_key": ""},
],
)
def test_s3_static_key_pair_is_none_without_a_full_pair(partial_s3_pair):
from litellm.llms.bedrock.common_utils import s3_static_key_pair
assert s3_static_key_pair({"aws_access_key_id": "bedrock-key", **partial_s3_pair}) is None

View file

@ -270,3 +270,40 @@ async def test_afile_content_assumes_role_with_external_id(monkeypatch):
assert s3_client_kwargs["aws_access_key_id"] == "ASIAFILESDOWNLOADROLE"
assert s3_client_kwargs["aws_session_token"] == "assumed-session-token"
assert response.content == b'{"custom_id": "req-1"}'
@pytest.mark.asyncio
async def test_afile_content_builds_the_s3_client_with_the_s3_pair_when_it_differs_from_the_aws_identity():
import boto3
class FakeS3Body:
def read(self):
return b'{"custom_id": "req-1"}'
class FakeS3Client:
def get_object(self, Bucket, Key):
return {"Body": FakeS3Body()}
optional_params = {
"_litellm_internal_model_credentials": MappingProxyType({"s3_bucket_name": "safe-bucket"}),
"aws_region_name": "us-east-1",
"aws_access_key_id": "AKIABEDROCKONLY",
"aws_secret_access_key": "bedrock-only-secret",
"aws_session_token": "bedrock-only-token",
"s3_access_key_id": "AKIAS3ONLY",
"s3_secret_access_key": "s3-only-secret",
}
with patch.object(boto3, "client", return_value=FakeS3Client()) as mock_boto3_client:
response = await BedrockFilesHandler().afile_content(
file_content_request={"file_id": "s3://safe-bucket/litellm-bedrock-files-model-id-abc.jsonl"},
optional_params=optional_params,
timeout=10.0,
max_retries=None,
)
s3_client_kwargs = mock_boto3_client.call_args.kwargs
assert s3_client_kwargs["aws_access_key_id"] == "AKIAS3ONLY"
assert s3_client_kwargs["aws_secret_access_key"] == "s3-only-secret"
assert s3_client_kwargs["aws_session_token"] is None, "the aws_* session token belongs to the Bedrock identity"
assert response.content == b'{"custom_id": "req-1"}'

View file

@ -6,6 +6,7 @@ import json
import os
from collections.abc import Mapping
from contextlib import AsyncExitStack, closing
from types import MappingProxyType
from typing import Final
from unittest.mock import MagicMock
from urllib.parse import unquote, urlparse
@ -3789,3 +3790,82 @@ class TestBedrockFileListTransformation:
assert denied.value.status_code == 403
assert "AccessDenied" in denied.value.message
_SPLIT_IDENTITY_PARAMS: Final = {
"aws_region_name": "us-east-1",
"aws_access_key_id": "AKIABEDROCKONLY",
"aws_secret_access_key": "bedrock-only-secret",
"s3_access_key_id": "AKIAS3ONLY",
"s3_secret_access_key": "s3-only-secret",
"s3_bucket_name": "safe-bucket",
}
def _authorization(headers: Mapping[str, str]) -> str:
return {key.lower(): value for key, value in headers.items()}["authorization"]
def test_sign_s3_request_uses_the_s3_pair_when_it_differs_from_the_aws_identity():
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
signed_headers, _signed_body = BedrockFilesConfig()._sign_s3_request(
content='{"custom_id": "req-1"}',
api_base="https://s3.us-east-1.amazonaws.com/safe-bucket/litellm-bedrock-files-model-id-abc.jsonl",
optional_params=dict(_SPLIT_IDENTITY_PARAMS),
)
assert _authorization(signed_headers).startswith("AWS4-HMAC-SHA256 Credential=AKIAS3ONLY/"), (
"the S3 PutObject must be signed by s3_access_key_id, not the Bedrock aws_access_key_id"
)
def test_sign_s3_request_with_the_s3_pair_ignores_ambient_aws_session_token_role_and_profile(monkeypatch):
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
monkeypatch.setenv("AWS_SESSION_TOKEN", "pod-token")
monkeypatch.setenv("AWS_ROLE_NAME", "arn:aws:iam::123456789012:role/pod")
monkeypatch.setenv("AWS_PROFILE_NAME", "pod-profile")
signed_headers, _signed_body = BedrockFilesConfig()._sign_s3_request(
content='{"custom_id": "req-1"}',
api_base="https://s3.us-east-1.amazonaws.com/safe-bucket/litellm-bedrock-files-model-id-abc.jsonl",
optional_params=dict(_SPLIT_IDENTITY_PARAMS),
)
lowered: Final = {key.lower(): value for key, value in signed_headers.items()}
assert lowered["authorization"].startswith("AWS4-HMAC-SHA256 Credential=AKIAS3ONLY/")
assert "x-amz-security-token" not in lowered, "an ambient AWS_SESSION_TOKEN must not be mixed into the s3_* pair"
@pytest.mark.parametrize("method", ["GET", "DELETE"])
def test_sign_s3_request_without_body_uses_the_s3_pair_when_it_differs_from_the_aws_identity(method):
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig, _BedrockS3RequestParams
signed_headers = BedrockFilesConfig()._sign_s3_request_without_body(
method=method,
api_base="https://s3.us-east-1.amazonaws.com/safe-bucket/litellm-bedrock-files-model-id-abc.jsonl",
aws_region_name="us-east-1",
request_params=_BedrockS3RequestParams.model_validate(_SPLIT_IDENTITY_PARAMS),
)
assert _authorization(signed_headers).startswith("AWS4-HMAC-SHA256 Credential=AKIAS3ONLY/"), (
f"the S3 {method} must be signed by s3_access_key_id, not the Bedrock aws_access_key_id"
)
def test_transform_file_content_request_signs_with_the_s3_pair_from_litellm_params():
from litellm.llms.bedrock.files.transformation import S3_SIGNED_REQUEST_HEADERS_PARAM, BedrockFilesConfig
litellm_params = {
**_SPLIT_IDENTITY_PARAMS,
"_litellm_internal_model_credentials": MappingProxyType({"s3_bucket_name": "safe-bucket"}),
}
BedrockFilesConfig().transform_file_content_request(
file_content_request={"file_id": "s3://safe-bucket/litellm-bedrock-files-model-id-abc.jsonl"},
optional_params={},
litellm_params=litellm_params,
)
assert _authorization(litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM]).startswith(
"AWS4-HMAC-SHA256 Credential=AKIAS3ONLY/"
)