Files
coder/aibridge/provider/bedrock_internal_test.go
T
Yevhenii Shcherbina ab69fa2f0d fix(aibridge/provider): disable keep-alive on the STS assume-role client (#26971)
A Bedrock provider that assumes an IAM role kept failing with
`AssumeRole` `AccessDenied` for several minutes after its target role's
trust policy was changed, and only recovered on a gateway restart or a
long wait. The request itself was correct: the AWS CLI, using the same
identity and the same `ExternalId`/role/region, accepted the identical
request immediately against the same endpoint.

The difference is the connection. The Go SDK reuses a keep-alive
connection for the STS client, so every `AssumeRole` rides one
connection pinned to a single STS endpoint. After a trust-policy change,
that connection kept returning `AccessDenied` for minutes while a fresh
connection (the AWS CLI) accepted the identical request at once; it
recovered only when the connection recycled or the process restarted.
The exact STS-internal reason is unconfirmed (likely per-endpoint
propagation of the change) — what is verified is that a fresh connection
per call recovers promptly.

Disable keep-alive on the STS client so each `AssumeRole` opens a fresh
connection and a trust-policy update takes effect quickly. `AssumeRole`
runs at most once per credential-cache lifetime, so keep-alive bought
nothing here. The change is scoped to the STS client only; Bedrock model
requests are signed by a separate client and keep their connection
pooling.

## What the data proves

| | CLI | Gateway |

|--------------------|-------------------------------------------|------------------------------------------|
| Identity / key | `bedrock-base-user-useless` / `AKIA…44NL` | same |
| STS endpoint | `sts.us-east-2.amazonaws.com` | same |
| Request params | `ExternalId=QL53…`, role, session, 900 | same |
| Recovery after fix | 7 seconds (21:27:54) | ~4.5 minutes (21:32:17) |
| Re-hitting AWS? | new call each time | yes — 77 fresh `AssumeRole`s,
all denied |

Same identity, params, and endpoint, concurrent — yet the gateway was
denied for ~4.5 minutes while the CLI recovered in 7 seconds, and the
gateway made a fresh `AssumeRole` on every request (so it was not
caching a failure). The only difference was connection reuse.

After disabling keep-alive, the same break/fix experiment brought
gateway recovery down from ~4.5 minutes to ~7 seconds, in lockstep with
the AWS CLI.
2026-07-02 18:58:17 +00:00

440 lines
16 KiB
Go

package provider
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/aibridge/config"
)
// TestBuildBedrockCredentialsValidation covers the input validation that does
// not require resolving credentials.
func TestBuildBedrockCredentialsValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg config.AWSBedrock
errorMsg string
}{
{
name: "missing region and base url",
cfg: config.AWSBedrock{},
errorMsg: "region or base url required",
},
{
name: "missing access key",
cfg: config.AWSBedrock{
Region: "us-east-1",
AccessKeySecret: "test-secret",
},
errorMsg: "both access key and access key secret must be provided together",
},
{
name: "missing access key secret",
cfg: config.AWSBedrock{
Region: "us-east-1",
AccessKey: "test-key",
},
errorMsg: "both access key and access key secret must be provided together",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, err := buildBedrockCredentials(context.Background(), tt.cfg)
require.Error(t, err)
require.Contains(t, err.Error(), tt.errorMsg)
})
}
}
// TestBuildBedrockCredentialsStatic resolves static credentials.
func TestBuildBedrockCredentialsStatic(t *testing.T) {
t.Parallel()
creds, _, err := buildBedrockCredentials(context.Background(), config.AWSBedrock{
Region: "us-east-1",
AccessKey: "test-key",
AccessKeySecret: "test-secret",
})
require.NoError(t, err)
got, err := creds.Retrieve(context.Background())
require.NoError(t, err)
require.Equal(t, "test-key", got.AccessKeyID)
require.Equal(t, "test-secret", got.SecretAccessKey)
}
// TestBuildBedrockCredentialsDefaultChain covers resolution via the AWS SDK
// default credential chain.
// NOTE: no t.Parallel() because the subtests use t.Setenv.
func TestBuildBedrockCredentialsDefaultChain(t *testing.T) {
tests := []struct {
name string
envVars map[string]string
expectError bool
wantKey string
wantSecret string
wantToken string
}{
{
name: "credentials via env",
envVars: map[string]string{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
},
wantKey: "test-key",
wantSecret: "test-secret",
},
{
name: "credentials with session token via env",
envVars: map[string]string{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_SESSION_TOKEN": "test-session-token",
},
wantKey: "test-key",
wantSecret: "test-secret",
wantToken: "test-session-token",
},
{
name: "error when no credential source is configured",
envVars: map[string]string{
"AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "",
"AWS_SESSION_TOKEN": "",
"AWS_PROFILE": "",
"AWS_SHARED_CREDENTIALS_FILE": "/dev/null",
"AWS_CONFIG_FILE": "/dev/null",
"AWS_WEB_IDENTITY_TOKEN_FILE": "",
"AWS_ROLE_ARN": "",
"AWS_ROLE_SESSION_NAME": "",
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "",
"AWS_CONTAINER_CREDENTIALS_FULL_URI": "",
"AWS_CONTAINER_AUTHORIZATION_TOKEN": "",
"AWS_EC2_METADATA_DISABLED": "true",
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for key, val := range tt.envVars {
t.Setenv(key, val)
}
// buildBedrockCredentials only wires up the provider chain; it
// does not resolve credentials, so it succeeds regardless of
// credential availability. Resolution failures surface on Retrieve.
creds, _, err := buildBedrockCredentials(context.Background(), config.AWSBedrock{
Region: "us-east-1",
})
require.NoError(t, err)
require.NotNil(t, creds)
got, err := creds.Retrieve(context.Background())
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantKey, got.AccessKeyID)
require.Equal(t, tt.wantSecret, got.SecretAccessKey)
require.Equal(t, tt.wantToken, got.SessionToken)
})
}
}
// TestBuildBedrockCredentialsAssumeRole drives the STS AssumeRole path against a
// mock endpoint, asserting that the configured role ARN and the stable session
// name are sent and that the returned temporary credentials are used.
// NOTE: no t.Parallel() because it uses t.Setenv.
func TestBuildBedrockCredentialsAssumeRole(t *testing.T) {
var gotRoleARN, gotSessionName, gotConnection string
// Mock the AWS STS AssumeRole API.
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
sts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
gotRoleARN = r.Form.Get("RoleArn")
gotSessionName = r.Form.Get("RoleSessionName")
// With keep-alive disabled, Go's HTTP client sends Connection: close.
gotConnection = r.Header.Get("Connection")
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write([]byte(`<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<AssumeRoleResult>
<Credentials>
<AccessKeyId>ASIAASSUMED</AccessKeyId>
<SecretAccessKey>assumed-secret</SecretAccessKey>
<SessionToken>assumed-token</SessionToken>
<Expiration>2999-01-01T00:00:00Z</Expiration>
</Credentials>
<AssumedRoleUser>
<Arn>arn:aws:sts::123456789012:assumed-role/target/coder</Arn>
<AssumedRoleId>AROAEXAMPLE:coder</AssumedRoleId>
</AssumedRoleUser>
</AssumeRoleResult>
</AssumeRoleResponse>`))
}))
defer sts.Close()
// Point the STS client at the mock and provide static base credentials so
// the base identity resolves without additional network calls.
t.Setenv("AWS_ENDPOINT_URL_STS", sts.URL)
t.Setenv("AWS_ACCESS_KEY_ID", "base-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "base-secret")
creds, _, err := buildBedrockCredentials(context.Background(), config.AWSBedrock{
Region: "us-east-1",
RoleARN: "arn:aws:iam::123456789012:role/target",
})
require.NoError(t, err)
got, err := creds.Retrieve(context.Background())
require.NoError(t, err)
require.Equal(t, "ASIAASSUMED", got.AccessKeyID)
require.Equal(t, "assumed-secret", got.SecretAccessKey)
require.Equal(t, "assumed-token", got.SessionToken)
require.Equal(t, "arn:aws:iam::123456789012:role/target", gotRoleARN)
require.Equal(t, bedrockSessionName, gotSessionName)
// The STS client disables keep-alive so each AssumeRole opens a fresh
// connection; Go signals this with a Connection: close request header.
require.Equal(t, "close", gotConnection,
"STS client should disable keep-alives so each AssumeRole opens a fresh connection")
}
// TestBuildBedrockCredentialsAssumeRoleExternalID verifies that a configured
// external ID is sent on the STS AssumeRole call, and that omitting it sends
// no ExternalId parameter.
// NOTE: no t.Parallel() because it uses t.Setenv.
func TestBuildBedrockCredentialsAssumeRoleExternalID(t *testing.T) {
tests := []struct {
name string
externalID string
wantExternalID string
}{
{name: "with external id", externalID: "trust-policy-id-123", wantExternalID: "trust-policy-id-123"},
{name: "without external id", externalID: "", wantExternalID: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotExternalID string
// Mock the AWS STS AssumeRole API.
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
sts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
gotExternalID = r.Form.Get("ExternalId")
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write([]byte(`<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<AssumeRoleResult>
<Credentials>
<AccessKeyId>ASIAASSUMED</AccessKeyId>
<SecretAccessKey>assumed-secret</SecretAccessKey>
<SessionToken>assumed-token</SessionToken>
<Expiration>2999-01-01T00:00:00Z</Expiration>
</Credentials>
</AssumeRoleResult>
</AssumeRoleResponse>`))
}))
defer sts.Close()
t.Setenv("AWS_ENDPOINT_URL_STS", sts.URL)
t.Setenv("AWS_ACCESS_KEY_ID", "base-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "base-secret")
creds, _, err := buildBedrockCredentials(context.Background(), config.AWSBedrock{
Region: "us-east-1",
RoleARN: "arn:aws:iam::123456789012:role/target",
ExternalID: tt.externalID,
})
require.NoError(t, err)
_, err = creds.Retrieve(context.Background())
require.NoError(t, err)
require.Equal(t, tt.wantExternalID, gotExternalID)
})
}
}
// TestBuildBedrockCredentialsAssumeRoleError verifies that when STS rejects the
// AssumeRole call (e.g. a trust-policy or IAM denial), the failure surfaces to
// the caller on Retrieve with enough detail to diagnose it, rather than being
// swallowed. The base identity resolved fine; only the role assumption failed.
// NOTE: no t.Parallel() because it uses t.Setenv.
func TestBuildBedrockCredentialsAssumeRoleError(t *testing.T) {
// Mock the AWS STS AssumeRole API.
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
sts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/xml")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`<ErrorResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<Error>
<Type>Sender</Type>
<Code>AccessDenied</Code>
<Message>User arn:aws:iam::123456789012:user/base is not authorized to perform sts:AssumeRole on arn:aws:iam::123456789012:role/target</Message>
</Error>
</ErrorResponse>`))
}))
defer sts.Close()
t.Setenv("AWS_ENDPOINT_URL_STS", sts.URL)
t.Setenv("AWS_ACCESS_KEY_ID", "base-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "base-secret")
creds, _, err := buildBedrockCredentials(context.Background(), config.AWSBedrock{
Region: "us-east-1",
RoleARN: "arn:aws:iam::123456789012:role/target",
})
require.NoError(t, err) // Build is lazy; the STS call happens on Retrieve.
_, err = creds.Retrieve(context.Background())
require.Error(t, err)
// The error must carry the STS operation and failure code so operators can
// tell this is an AssumeRole authorization problem, not missing credentials.
require.ErrorContains(t, err, "AssumeRole")
require.ErrorContains(t, err, "AccessDenied")
}
// TestBuildBedrockCredentialsAssumeRoleCaches verifies the AssumeRole result is
// cached: many credential retrievals, one per LLM request, trigger a single STS
// AssumeRole call rather than re-assuming the role on every request.
// NOTE: no t.Parallel() because it uses t.Setenv.
func TestBuildBedrockCredentialsAssumeRoleCaches(t *testing.T) {
var stsCalls atomic.Int64
// Mock the AWS STS AssumeRole API.
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
sts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
stsCalls.Add(1)
w.Header().Set("Content-Type", "text/xml")
// A far-future expiration keeps the cached credentials valid, so the
// cache serves every retrieval after the first without re-assuming.
_, _ = w.Write([]byte(`<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<AssumeRoleResult>
<Credentials>
<AccessKeyId>ASIAASSUMED</AccessKeyId>
<SecretAccessKey>assumed-secret</SecretAccessKey>
<SessionToken>assumed-token</SessionToken>
<Expiration>2999-01-01T00:00:00Z</Expiration>
</Credentials>
</AssumeRoleResult>
</AssumeRoleResponse>`))
}))
defer sts.Close()
t.Setenv("AWS_ENDPOINT_URL_STS", sts.URL)
t.Setenv("AWS_ACCESS_KEY_ID", "base-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "base-secret")
creds, _, err := buildBedrockCredentials(context.Background(), config.AWSBedrock{
Region: "us-east-1",
RoleARN: "arn:aws:iam::123456789012:role/target",
})
require.NoError(t, err)
// Each retrieval stands in for an LLM request resolving credentials from the
// shared provider. Only the first should reach STS.
for range 5 {
got, err := creds.Retrieve(context.Background())
require.NoError(t, err)
require.Equal(t, "ASIAASSUMED", got.AccessKeyID)
}
require.Equal(t, int64(1), stsCalls.Load(),
"AssumeRole should be called once, then served from the credentials cache")
}
// TestBuildBedrockCredentialsAssumeRoleRefreshesOnExpiry verifies that once the
// assumed credentials expire, the next retrieval re-assumes the role rather than
// serving stale credentials from the cache.
// NOTE: no t.Parallel() because it uses t.Setenv.
func TestBuildBedrockCredentialsAssumeRoleRefreshesOnExpiry(t *testing.T) {
var stsCalls atomic.Int64
// Mock the AWS STS AssumeRole API.
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
sts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
stsCalls.Add(1)
w.Header().Set("Content-Type", "text/xml")
// An expiration in the past makes the returned credentials immediately
// stale, so the cache cannot reuse them and must re-assume on the next
// retrieval.
_, _ = w.Write([]byte(`<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<AssumeRoleResult>
<Credentials>
<AccessKeyId>ASIAASSUMED</AccessKeyId>
<SecretAccessKey>assumed-secret</SecretAccessKey>
<SessionToken>assumed-token</SessionToken>
<Expiration>2000-01-01T00:00:00Z</Expiration>
</Credentials>
</AssumeRoleResult>
</AssumeRoleResponse>`))
}))
defer sts.Close()
t.Setenv("AWS_ENDPOINT_URL_STS", sts.URL)
t.Setenv("AWS_ACCESS_KEY_ID", "base-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "base-secret")
creds, _, err := buildBedrockCredentials(context.Background(), config.AWSBedrock{
Region: "us-east-1",
RoleARN: "arn:aws:iam::123456789012:role/target",
})
require.NoError(t, err)
_, err = creds.Retrieve(context.Background())
require.NoError(t, err)
_, err = creds.Retrieve(context.Background())
require.NoError(t, err)
require.Equal(t, int64(2), stsCalls.Load(),
"expired credentials should trigger a fresh AssumeRole on the next retrieval")
}
// TestBuildBedrockCredentialsAssumeRoleRequiresRegion verifies that configuring
// a role without a resolvable region fails at construction. STS needs a region
// to resolve its endpoint.
// NOTE: no t.Parallel() because it uses t.Setenv.
func TestBuildBedrockCredentialsAssumeRoleRequiresRegion(t *testing.T) {
// Ensure no region resolves from the environment, shared config, or IMDS,
// so base.Region ends up empty.
t.Setenv("AWS_REGION", "")
t.Setenv("AWS_DEFAULT_REGION", "")
t.Setenv("AWS_PROFILE", "")
t.Setenv("AWS_CONFIG_FILE", "/dev/null")
t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null")
t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
_, _, err := buildBedrockCredentials(context.Background(), config.AWSBedrock{
BaseURL: "https://bedrock-runtime.example.com",
RoleARN: "arn:aws:iam::123456789012:role/target",
})
require.ErrorContains(t, err, "region is required to assume a role")
}
// TestBuildBedrockCredentialsAssumeRoleRegionFromEnv verifies that a role
// configured without an explicit region resolves it from the AWS environment
// (AWS_REGION here).
// NOTE: no t.Parallel() because it uses t.Setenv.
func TestBuildBedrockCredentialsAssumeRoleRegionFromEnv(t *testing.T) {
t.Setenv("AWS_REGION", "us-west-2")
// BaseURL set with no explicit region: the region comes from AWS_REGION.
_, region, err := buildBedrockCredentials(context.Background(), config.AWSBedrock{
BaseURL: "https://bedrock-runtime.example.com",
RoleARN: "arn:aws:iam::123456789012:role/target",
})
require.NoError(t, err)
require.Equal(t, "us-west-2", region)
}