Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions aibridge/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ type AWSBedrock struct {
// IRSA / EKS Pod Identity / EC2 Instance Profile) signs the AssumeRole
// call, and the resulting temporary credentials sign Bedrock requests.
RoleARN string
// ExternalID is sent as the STS external ID on the AssumeRole call.
// It is meaningful only alongside RoleARN and must match the
// sts:ExternalId condition on the target role's trust policy.
ExternalID string
}

// OpenAI carries configuration for an OpenAI provider.
Expand Down
3 changes: 3 additions & 0 deletions aibridge/provider/bedrock.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ func buildBedrockCredentials(ctx context.Context, cfg config.AWSBedrock) (aws.Cr
if cfg.RoleARN != "" {
credsProvider = stscreds.NewAssumeRoleProvider(sts.NewFromConfig(base), cfg.RoleARN, func(o *stscreds.AssumeRoleOptions) {
o.RoleSessionName = bedrockSessionName
if cfg.ExternalID != "" {
o.ExternalID = aws.String(cfg.ExternalID)
}
})
credsProvider = aws.NewCredentialsCache(credsProvider)
}
Expand Down
55 changes: 55 additions & 0 deletions aibridge/provider/bedrock_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,61 @@ func TestBuildBedrockCredentialsAssumeRole(t *testing.T) {
require.Equal(t, bedrockSessionName, gotSessionName)
}

// 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
Expand Down
2 changes: 2 additions & 0 deletions cli/aibridged.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ func protoToProviderSpec(pp *proto.AIProvider) aiProviderSpec {
b.GetSmallFastModel(),
)
bedrock.RoleARN = b.GetRoleArn()
bedrock.ExternalID = b.GetExternalId()
spec.Bedrock = ptr.Ref(bedrock)
}
return spec
Expand Down Expand Up @@ -352,6 +353,7 @@ func bedrockConfig(baseURL string, bedrock *codersdk.AIProviderBedrockSettings)
Model: bedrockSettings.Model,
SmallFastModel: bedrockSettings.SmallFastModel,
RoleARN: bedrockSettings.RoleARN,
ExternalID: bedrockSettings.ExternalID,
}
}

Expand Down
55 changes: 55 additions & 0 deletions coderd/ai_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package coderd

import (
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"errors"
Expand Down Expand Up @@ -178,6 +179,9 @@ func (api *API) aiProvidersCreate(rw http.ResponseWriter, r *http.Request) {
return
}

// Generate the server-owned external ID when the provider assumes a role.
ensureBedrockExternalID(&req.Settings)

settings, err := encodeAIProviderSettings(req.Settings)
if err != nil {
api.Logger.Error(ctx, "encode AI provider settings", slog.Error(err))
Expand Down Expand Up @@ -318,6 +322,9 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
return xerrors.Errorf("decode existing settings: %w", err)
}
if req.Settings != nil {
if err := validateBedrockExternalIDUnchanged(existing, *req.Settings); err != nil {
return err
}
existing = mergeAIProviderSettings(existing, *req.Settings)
}
// Bedrock settings are only meaningful for anthropic- or
Expand All @@ -329,6 +336,9 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
old.Type != database.AIProviderTypeBedrock {
return errAIProviderBedrockTypeMismatch
}
// Generate the server-owned external ID when the provider assumes a role
// and lacks one.
ensureBedrockExternalID(&existing)
settings, err := encodeAIProviderSettings(existing)
if err != nil {
return xerrors.Errorf("encode settings: %w", err)
Expand Down Expand Up @@ -400,6 +410,12 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
})
return
}
if errors.Is(err, errAIProviderExternalIDReadOnly) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "The Bedrock external ID is server-generated and cannot be changed.",
})
return
}
if errors.Is(err, errAIProviderKeyUnknown) {
// Use the sentinel directly so the response message does not
// leak the "execute transaction:" wrapper xerrors added on the
Expand Down Expand Up @@ -506,6 +522,12 @@ var errCopilotRejectsAPIKeys = xerrors.New("copilot providers do not accept api_
// the outer handler translates it into a 400.
var errAIProviderBedrockTypeMismatch = xerrors.New("bedrock settings are only valid for type=anthropic or type=bedrock")

// errAIProviderExternalIDReadOnly is the sentinel returned from inside
// the update transaction when a patch tries to change the server-owned
// Bedrock external ID; the outer handler translates it into a 400. A
// patch may echo the stored value but not set a different one.
var errAIProviderExternalIDReadOnly = xerrors.New("external_id is server-generated and cannot be changed")

// errAIProviderInvalidName is returned from lookupAIProvider when the
// idOrName parameter is neither a UUID nor a syntactically-valid name.
// The handler translates this into a 400 so an integrator gets a hint
Expand Down Expand Up @@ -772,6 +794,39 @@ func mergeAIProviderSettings(existing, patch codersdk.AIProviderSettings) coders
if merged.AccessKeySecret == nil {
merged.AccessKeySecret = existing.Bedrock.AccessKeySecret
}
// The external ID is server-owned and stable: carry the stored value
// forward so a patch can't change it. A patch that sets a different
// value is rejected upstream.
merged.ExternalID = existing.Bedrock.ExternalID

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-3] The ExternalID survives role removal (carried forward unconditionally here) and is reused when a different role is later added. The PR description says "stable thereafter," so this is by design. But there is no test for the remove-then-readd sequence: create with role A → PATCH to clear RoleARN → PATCH to add role B → assert same ExternalID.

Without that test, a future refactor could accidentally clear the ExternalID on role removal, silently breaking trust policies. (Ryosuke)

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: d2d0bce

}
return codersdk.AIProviderSettings{Bedrock: &merged}
}

// validateBedrockExternalIDUnchanged rejects a patch that sets a Bedrock
// external ID different from the stored one. A patch may echo the stored
// value (read-modify-write resends it) but not change it; the value is
// server-owned.
Comment on lines +805 to +808

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-4] The func doc restates the function name. The one trap worth preserving is the echo-vs-change asymmetry (read-modify-write sends the stored value back and must be accepted). Consider:

// validateBedrockExternalIDUnchanged allows echoing the stored value
// (read-modify-write) but rejects a changed one.

Similarly, the merge comment at line 797-799 could compress to // Server-owned: carry the stored value forward. (Gon)

🤖

func validateBedrockExternalIDUnchanged(existing, patch codersdk.AIProviderSettings) error {
stored := ""
if existing.Bedrock != nil {
stored = existing.Bedrock.ExternalID
}

provided := ""
if patch.Bedrock != nil {
provided = patch.Bedrock.ExternalID
}

if provided != "" && provided != stored {
return errAIProviderExternalIDReadOnly
}
return nil
}

// ensureBedrockExternalID assigns a server-owned STS external ID when the
// Bedrock provider assumes a role and none is set yet.
func ensureBedrockExternalID(s *codersdk.AIProviderSettings) {
if s.Bedrock != nil && s.Bedrock.RoleARN != "" && s.Bedrock.ExternalID == "" {
s.Bedrock.ExternalID = rand.Text()
}
}
89 changes: 89 additions & 0 deletions coderd/ai_providers_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package coderd

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/codersdk"
)

// TestEnsureBedrockExternalID covers the server-owned external ID generation:
// it generates only when a role is configured and none is set, and never
// overwrites an existing value.
func TestEnsureBedrockExternalID(t *testing.T) {
t.Parallel()

t.Run("NilBedrockIsNoOp", func(t *testing.T) {
t.Parallel()
s := codersdk.AIProviderSettings{}
ensureBedrockExternalID(&s)
require.Nil(t, s.Bedrock)
})

t.Run("NoRoleLeavesEmpty", func(t *testing.T) {
t.Parallel()
s := codersdk.AIProviderSettings{Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1"}}
ensureBedrockExternalID(&s)
require.Empty(t, s.Bedrock.ExternalID)
})

t.Run("GeneratesWhenRoleSet", func(t *testing.T) {
t.Parallel()
s := codersdk.AIProviderSettings{Bedrock: &codersdk.AIProviderBedrockSettings{
RoleARN: "arn:aws:iam::123456789012:role/BedrockRole",
}}
ensureBedrockExternalID(&s)
// The bounds are a sanity floor and ceiling, not a correctness
// requirement. crypto/rand.Text() currently returns 26 chars, but
// its docs allow future Go versions to return longer text. If a Go
// upgrade trips these bounds, widen them or use different function.
require.GreaterOrEqual(t, len(s.Bedrock.ExternalID), 26)
require.LessOrEqual(t, len(s.Bedrock.ExternalID), 52)
Comment thread
dannykopping marked this conversation as resolved.
})

t.Run("DoesNotOverwriteExisting", func(t *testing.T) {
t.Parallel()
s := codersdk.AIProviderSettings{Bedrock: &codersdk.AIProviderBedrockSettings{
RoleARN: "arn:aws:iam::123456789012:role/BedrockRole",
ExternalID: "existing-value",
}}
ensureBedrockExternalID(&s)
require.Equal(t, "existing-value", s.Bedrock.ExternalID)
})

t.Run("GeneratesUniqueValues", func(t *testing.T) {
t.Parallel()
seen := make(map[string]struct{})
for range 10 {
s := codersdk.AIProviderSettings{Bedrock: &codersdk.AIProviderBedrockSettings{
RoleARN: "arn:aws:iam::123456789012:role/BedrockRole",
}}
ensureBedrockExternalID(&s)
_, dup := seen[s.Bedrock.ExternalID]
require.False(t, dup, "external IDs must be unique per provider")
seen[s.Bedrock.ExternalID] = struct{}{}
}
})
}

// TestMergeAIProviderSettingsExternalID verifies the external ID is treated as
// server-owned during a PATCH merge: a stored value is carried forward and
// overrides the patch so it can't be changed.
func TestMergeAIProviderSettingsExternalID(t *testing.T) {
t.Parallel()

roleARN := "arn:aws:iam::123456789012:role/BedrockRole"
existing := codersdk.AIProviderSettings{Bedrock: &codersdk.AIProviderBedrockSettings{
RoleARN: roleARN,
ExternalID: "stored-value",
}}
patch := codersdk.AIProviderSettings{Bedrock: &codersdk.AIProviderBedrockSettings{
RoleARN: roleARN,
ExternalID: "client-supplied-value",
}}
merged := mergeAIProviderSettings(existing, patch)
require.NotNil(t, merged.Bedrock)
require.Equal(t, roleARN, merged.Bedrock.RoleARN)
require.Equal(t, "stored-value", merged.Bedrock.ExternalID)
}
Loading
Loading