-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: generate STS external ID for Bedrock role assumption #26869
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2573afa
f7178f5
ab9b110
d2d0bce
5deff01
229667e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ package coderd | |
|
|
||
| import ( | ||
| "context" | ||
| "crypto/rand" | ||
| "database/sql" | ||
| "encoding/json" | ||
| "errors" | ||
|
|
@@ -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)) | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||
| 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() | ||
| } | ||
| } | ||
| 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) | ||
|
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) | ||
| } | ||
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: d2d0bce