From 2573afab2529f1fcd96763dcc51e0456c70f5836 Mon Sep 17 00:00:00 2001 From: evgeniy-scherbina Date: Tue, 30 Jun 2026 08:44:32 -0400 Subject: [PATCH 1/6] feat: generate STS external ID for Bedrock role assumption --- aibridge/config/config.go | 4 + aibridge/provider/bedrock.go | 3 + aibridge/provider/bedrock_internal_test.go | 55 ++++++ cli/aibridged.go | 2 + coderd/ai_providers.go | 55 ++++++ coderd/ai_providers_internal_test.go | 86 ++++++++++ coderd/ai_providers_test.go | 185 +++++++++++++++++++++ coderd/aibridged/proto/aibridged.pb.go | 142 ++++++++-------- coderd/aibridged/proto/aibridged.proto | 1 + coderd/aibridgedserver/aibridgedserver.go | 1 + codersdk/aiproviders.go | 6 + codersdk/aiproviders_bedrock.go | 5 + site/src/api/typesGenerated.ts | 10 ++ 13 files changed, 489 insertions(+), 66 deletions(-) create mode 100644 coderd/ai_providers_internal_test.go diff --git a/aibridge/config/config.go b/aibridge/config/config.go index 3dc76841aa3..b01282b0cc7 100644 --- a/aibridge/config/config.go +++ b/aibridge/config/config.go @@ -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. diff --git a/aibridge/provider/bedrock.go b/aibridge/provider/bedrock.go index 93bbf7038a0..013430f1d7a 100644 --- a/aibridge/provider/bedrock.go +++ b/aibridge/provider/bedrock.go @@ -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) } diff --git a/aibridge/provider/bedrock_internal_test.go b/aibridge/provider/bedrock_internal_test.go index 0ecae6c780b..1cb7764c894 100644 --- a/aibridge/provider/bedrock_internal_test.go +++ b/aibridge/provider/bedrock_internal_test.go @@ -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(` + + + ASIAASSUMED + assumed-secret + assumed-token + 2999-01-01T00:00:00Z + + +`)) + })) + 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 diff --git a/cli/aibridged.go b/cli/aibridged.go index 87e583b5736..addeb3f9598 100644 --- a/cli/aibridged.go +++ b/cli/aibridged.go @@ -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 @@ -352,6 +353,7 @@ func bedrockConfig(baseURL string, bedrock *codersdk.AIProviderBedrockSettings) Model: bedrockSettings.Model, SmallFastModel: bedrockSettings.SmallFastModel, RoleARN: bedrockSettings.RoleARN, + ExternalID: bedrockSettings.ExternalID, } } diff --git a/coderd/ai_providers.go b/coderd/ai_providers.go index 49e5bb87504..a00e0268006 100644 --- a/coderd/ai_providers.go +++ b/coderd/ai_providers.go @@ -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. +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() + } +} diff --git a/coderd/ai_providers_internal_test.go b/coderd/ai_providers_internal_test.go new file mode 100644 index 00000000000..665a4dbfa0a --- /dev/null +++ b/coderd/ai_providers_internal_test.go @@ -0,0 +1,86 @@ +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) + require.NotEmpty(t, s.Bedrock.ExternalID) + // crypto/rand.Text returns a 26-character base32 string. + require.Len(t, s.Bedrock.ExternalID, 26) + }) + + 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) +} diff --git a/coderd/ai_providers_test.go b/coderd/ai_providers_test.go index 4fcc1dd63bc..6897ff7088d 100644 --- a/coderd/ai_providers_test.go +++ b/coderd/ai_providers_test.go @@ -1554,3 +1554,188 @@ func TestAIProviderSettingsMerge(t *testing.T) { require.Equal(t, "", *persisted.Bedrock.AccessKeySecret) }) } + +// TestAIProvidersBedrockExternalID covers the server-owned STS external ID: +// it is generated when (and only when) the provider assumes a role, is +// rejected when a client tries to set or change it, and is stable across +// PATCHes that echo the stored value. +func TestAIProvidersBedrockExternalID(t *testing.T) { + t.Parallel() + + const ( + roleARN = "arn:aws:iam::123456789012:role/BedrockRole" + externalIDReadOnlyMsg = "The Bedrock external ID is server-generated and cannot be changed." + ) + + createBedrock := func(t *testing.T, client *codersdk.Client, name string, b codersdk.AIProviderBedrockSettings) (codersdk.AIProvider, error) { + t.Helper() + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // Owner role is the audience for this endpoint. + return client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeBedrock, + Name: name, + Enabled: true, + BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com", + Settings: codersdk.AIProviderSettings{Bedrock: &b}, + }) + } + + t.Run("GeneratedWhenRoleSet", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + created, err := createBedrock(t, client, "bedrock-role", codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + RoleARN: roleARN, + }) + require.NoError(t, err) + require.NotNil(t, created.Settings.Bedrock) + require.NotEmpty(t, created.Settings.Bedrock.ExternalID, "external ID must be generated when a role is set") + + // GET returns the same external ID. + got, err := client.AIProvider(ctx, created.ID.String()) + require.NoError(t, err) + require.Equal(t, created.Settings.Bedrock.ExternalID, got.Settings.Bedrock.ExternalID) + }) + + t.Run("AbsentWithoutRole", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + created, err := createBedrock(t, client, "bedrock-no-role", codersdk.AIProviderBedrockSettings{Region: "us-east-1"}) + require.NoError(t, err) + require.NotNil(t, created.Settings.Bedrock) + require.Empty(t, created.Settings.Bedrock.ExternalID, "no external ID without a role to assume") + }) + + t.Run("RejectsClientValueOnCreate", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := createBedrock(t, client, "bedrock-client-id", codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + RoleARN: roleARN, + ExternalID: "client-supplied-value", + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Validations, codersdk.ValidationError{ + Field: "settings.external_id", + Detail: "external_id is server-generated and cannot be set", + }) + }) + + t.Run("StableWhenPatchOmitsValue", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + created, err := createBedrock(t, client, "bedrock-stable", codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + RoleARN: roleARN, + }) + require.NoError(t, err) + original := created.Settings.Bedrock.ExternalID + require.NotEmpty(t, original) + + updated, err := client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-west-2", RoleARN: roleARN}, + }, + }) + require.NoError(t, err) + require.Equal(t, "us-west-2", updated.Settings.Bedrock.Region) + require.Equal(t, original, updated.Settings.Bedrock.ExternalID, "external ID must be stable across PATCH") + }) + + t.Run("AllowsEchoedValueOnPatch", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + created, err := createBedrock(t, client, "bedrock-echo", codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + RoleARN: roleARN, + }) + require.NoError(t, err) + original := created.Settings.Bedrock.ExternalID + require.NotEmpty(t, original) + + // Read-modify-write resends the whole settings, including the stored + // external ID. Echoing the same value is allowed. + updated, err := client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-west-2", RoleARN: roleARN, ExternalID: original}, + }, + }) + require.NoError(t, err) + require.Equal(t, "us-west-2", updated.Settings.Bedrock.Region) + require.Equal(t, original, updated.Settings.Bedrock.ExternalID) + }) + + t.Run("RejectsChangedValueOnPatch", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + created, err := createBedrock(t, client, "bedrock-change", codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + RoleARN: roleARN, + }) + require.NoError(t, err) + require.NotEmpty(t, created.Settings.Bedrock.ExternalID) + + _, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1", RoleARN: roleARN, ExternalID: "client-tries-to-change-it"}, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, externalIDReadOnlyMsg, sdkErr.Message) + }) + + t.Run("GeneratedWhenRoleAddedByPatch", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + created, err := createBedrock(t, client, "bedrock-add-role", codersdk.AIProviderBedrockSettings{Region: "us-east-1"}) + require.NoError(t, err) + require.Empty(t, created.Settings.Bedrock.ExternalID) + + updated, err := client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1", RoleARN: roleARN}, + }, + }) + require.NoError(t, err) + require.NotEmpty(t, updated.Settings.Bedrock.ExternalID, "external ID must be generated when a role is added by PATCH") + }) + + t.Run("RejectsClientValueWhenRoleAddedByPatch", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + created, err := createBedrock(t, client, "bedrock-add-role-id", codersdk.AIProviderBedrockSettings{Region: "us-east-1"}) + require.NoError(t, err) + require.Empty(t, created.Settings.Bedrock.ExternalID) + + // No value is stored yet, so any client value is a change and is rejected. + _, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1", RoleARN: roleARN, ExternalID: "client-supplied-value"}, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, externalIDReadOnlyMsg, sdkErr.Message) + }) +} diff --git a/coderd/aibridged/proto/aibridged.pb.go b/coderd/aibridged/proto/aibridged.pb.go index f503aaa8fb7..b4be124d972 100644 --- a/coderd/aibridged/proto/aibridged.pb.go +++ b/coderd/aibridged/proto/aibridged.pb.go @@ -1454,6 +1454,7 @@ type AIProviderKindBedrock struct { Model string `protobuf:"bytes,4,opt,name=model,proto3" json:"model,omitempty"` SmallFastModel string `protobuf:"bytes,5,opt,name=small_fast_model,json=smallFastModel,proto3" json:"small_fast_model,omitempty"` RoleArn string `protobuf:"bytes,6,opt,name=role_arn,json=roleArn,proto3" json:"role_arn,omitempty"` + ExternalId string `protobuf:"bytes,7,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` } func (x *AIProviderKindBedrock) Reset() { @@ -1530,6 +1531,13 @@ func (x *AIProviderKindBedrock) GetRoleArn() string { return "" } +func (x *AIProviderKindBedrock) GetExternalId() string { + if x != nil { + return x.ExternalId + } + return "" +} + var File_coderd_aibridged_proto_aibridged_proto protoreflect.FileDescriptor var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ @@ -1802,7 +1810,7 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ 0x6b, 0x65, 0x79, 0x73, 0x12, 0x36, 0x0a, 0x07, 0x62, 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4b, 0x69, 0x6e, 0x64, 0x42, 0x65, 0x64, 0x72, - 0x6f, 0x63, 0x6b, 0x52, 0x07, 0x62, 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x22, 0xd5, 0x01, 0x0a, + 0x6f, 0x63, 0x6b, 0x52, 0x07, 0x62, 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x22, 0xf6, 0x01, 0x0a, 0x15, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4b, 0x69, 0x6e, 0x64, 0x42, 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x12, 0x1d, @@ -1816,72 +1824,74 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x6d, 0x61, 0x6c, 0x6c, 0x46, 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x61, 0x72, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x72, 0x6f, 0x6c, - 0x65, 0x41, 0x72, 0x6e, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, - 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, - 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, - 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, - 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, - 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, - 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, - 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, - 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, - 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, + 0x65, 0x41, 0x72, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x72, + 0x6e, 0x61, 0x6c, 0x49, 0x64, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, + 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, + 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, + 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, + 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, + 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, + 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, - 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, - 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, - 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, - 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, - 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, - 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, - 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, - 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, - 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x55, - 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, - 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x65, 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, - 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x4d, 0x0a, - 0x0e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, - 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, - 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, - 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, - 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, + 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, + 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, + 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, + 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, + 0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, + 0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x65, 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x4d, + 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, + 0x12, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, + 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x64, 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/coderd/aibridged/proto/aibridged.proto b/coderd/aibridged/proto/aibridged.proto index 1d22ccdaa81..8dc78e7a894 100644 --- a/coderd/aibridged/proto/aibridged.proto +++ b/coderd/aibridged/proto/aibridged.proto @@ -193,4 +193,5 @@ message AIProviderKindBedrock { string model = 4; string small_fast_model = 5; string role_arn = 6; + string external_id = 7; } diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 78f11cd9cc8..8b539153e98 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -883,6 +883,7 @@ func aiProviderToProto(row database.AIProvider, keys []database.AIProviderKey) ( Model: settings.Bedrock.Model, SmallFastModel: settings.Bedrock.SmallFastModel, RoleArn: settings.Bedrock.RoleARN, + ExternalId: settings.Bedrock.ExternalID, } } diff --git a/codersdk/aiproviders.go b/codersdk/aiproviders.go index 6cf8fb3359e..bca27f13610 100644 --- a/codersdk/aiproviders.go +++ b/codersdk/aiproviders.go @@ -249,6 +249,12 @@ func (req CreateAIProviderRequest) Validate() []ValidationError { } if req.Settings.Bedrock != nil { validations = append(validations, validateAIProviderRoleARN(req.Settings.Bedrock.RoleARN)...) + if req.Settings.Bedrock.ExternalID != "" { + validations = append(validations, ValidationError{ + Field: "settings.external_id", + Detail: "external_id is server-generated and cannot be set", + }) + } } if req.Type == AIProviderTypeCopilot && len(req.APIKeys) > 0 { validations = append(validations, ValidationError{ diff --git a/codersdk/aiproviders_bedrock.go b/codersdk/aiproviders_bedrock.go index 360b763333c..be17f6c95d0 100644 --- a/codersdk/aiproviders_bedrock.go +++ b/codersdk/aiproviders_bedrock.go @@ -35,6 +35,11 @@ type AIProviderBedrockSettings struct { // IRSA / EKS Pod Identity / EC2 Instance Profile) signs the AssumeRole // call, and the resulting temporary credentials sign Bedrock requests. RoleARN string `json:"role_arn,omitempty"` + // ExternalID is the STS external ID sent on the AssumeRole call when + // RoleARN is set. The server generates and owns it: create and update + // reject any client-supplied value that differs from the stored one (an + // update may echo the stored value back). + ExternalID string `json:"external_id,omitempty"` } // IsConfigured reports whether any load-bearing Bedrock field is set, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index cd7ca431860..94de690172d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -335,6 +335,16 @@ export interface AIProviderBedrockSettings { * call, and the resulting temporary credentials sign Bedrock requests. */ readonly role_arn?: string; + /** + * ExternalID is the STS external ID sent on the AssumeRole call when + * RoleARN is set. The server generates and owns it: create and update + * reject any client-supplied value that differs from the stored one (an + * update may echo the stored value back). Operators add it to the target + * role's trust policy as an sts:ExternalId condition to constrain the + * assumption to this deployment. It is not a secret and is returned on + * GET. + */ + readonly external_id?: string; } // From codersdk/aiproviders_bedrock.go From f7178f552977739a0148fa6040e947fe73c364e7 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 1 Jul 2026 13:54:41 +0000 Subject: [PATCH 2/6] fix: CR's fixes --- coderd/ai_providers_internal_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/coderd/ai_providers_internal_test.go b/coderd/ai_providers_internal_test.go index 665a4dbfa0a..ac86e45fbdf 100644 --- a/coderd/ai_providers_internal_test.go +++ b/coderd/ai_providers_internal_test.go @@ -34,9 +34,8 @@ func TestEnsureBedrockExternalID(t *testing.T) { RoleARN: "arn:aws:iam::123456789012:role/BedrockRole", }} ensureBedrockExternalID(&s) - require.NotEmpty(t, s.Bedrock.ExternalID) - // crypto/rand.Text returns a 26-character base32 string. - require.Len(t, s.Bedrock.ExternalID, 26) + require.GreaterOrEqual(t, len(s.Bedrock.ExternalID), 26) + require.LessOrEqual(t, len(s.Bedrock.ExternalID), 52) }) t.Run("DoesNotOverwriteExisting", func(t *testing.T) { From ab9b1100a08f02bc0fb4e7cceb6c58a4715646fc Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 1 Jul 2026 13:59:40 +0000 Subject: [PATCH 3/6] fix: CR's fixes --- site/src/api/typesGenerated.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 94de690172d..2c6d5717eaf 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -339,10 +339,7 @@ export interface AIProviderBedrockSettings { * ExternalID is the STS external ID sent on the AssumeRole call when * RoleARN is set. The server generates and owns it: create and update * reject any client-supplied value that differs from the stored one (an - * update may echo the stored value back). Operators add it to the target - * role's trust policy as an sts:ExternalId condition to constrain the - * assumption to this deployment. It is not a secret and is returned on - * GET. + * update may echo the stored value back). */ readonly external_id?: string; } From d2d0bceea64c0374fc7ed8520af695802033eef0 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 1 Jul 2026 14:09:59 +0000 Subject: [PATCH 4/6] fix: CR's fixes --- coderd/ai_providers_test.go | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/coderd/ai_providers_test.go b/coderd/ai_providers_test.go index 6897ff7088d..9ebd787a7c7 100644 --- a/coderd/ai_providers_test.go +++ b/coderd/ai_providers_test.go @@ -1652,6 +1652,44 @@ func TestAIProvidersBedrockExternalID(t *testing.T) { require.Equal(t, original, updated.Settings.Bedrock.ExternalID, "external ID must be stable across PATCH") }) + t.Run("StableAcrossRoleRemovalAndReadd", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + const roleB = "arn:aws:iam::123456789012:role/BedrockRoleB" + + created, err := createBedrock(t, client, "bedrock-toggle", codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + RoleARN: roleARN, + }) + require.NoError(t, err) + original := created.Settings.Bedrock.ExternalID + require.NotEmpty(t, original) + + // Removing the role retains the external ID. + cleared, err := client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1"}, + }, + }) + require.NoError(t, err) + require.Empty(t, cleared.Settings.Bedrock.RoleARN) + require.Equal(t, original, cleared.Settings.Bedrock.ExternalID) + + // Adding a different role reuses the retained ID rather than + // regenerating it, so a trust policy referencing it keeps working. + readded, err := client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1", RoleARN: roleB}, + }, + }) + require.NoError(t, err) + require.Equal(t, roleB, readded.Settings.Bedrock.RoleARN) + require.Equal(t, original, readded.Settings.Bedrock.ExternalID, "external ID must survive role removal and re-add") + }) + t.Run("AllowsEchoedValueOnPatch", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) From a3b92b788cb6b6bae9fae7ca7ce56b3dff0d3189 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 1 Jul 2026 16:07:58 +0000 Subject: [PATCH 5/6] feat: show Bedrock external ID in the provider edit form --- .../UpdateProviderPageView.tsx | 2 ++ .../ProvidersPage/components/ProviderForm.tsx | 16 ++++++++++ .../components/providerFormApiMap.test.ts | 31 +++++++++++++++++++ .../components/providerFormApiMap.ts | 11 +++++++ 4 files changed, 60 insertions(+) diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx index dd4dd96599a..97ed87b975e 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx @@ -23,6 +23,7 @@ import { ProviderForm } from "../components/ProviderForm"; import { getProviderIcon } from "../components/ProviderIcon"; import { aiProviderToFormValues, + bedrockExternalId, getProviderDisplayType, hasBedrockStoredCredentials, isBedrockProvider, @@ -196,6 +197,7 @@ const UpdateProviderPageView: React.FC = () => { bedrockSavedAccessCredentials={hasBedrockStoredCredentials( provider, )} + bedrockExternalId={bedrockExternalId(provider)} openAiAnthropicSavedApiKey={openAiAnthropicSavedApiKey} openAiAnthropicMaskedApiKey={openAiAnthropicMaskedApiKey} initialValues={aiProviderToFormValues(provider)} diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index 6a2b1dfc4ac..7a87b06d14a 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -6,9 +6,11 @@ import * as Yup from "yup"; import type { AIProviderType } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; +import { CodeExample } from "#/components/CodeExample/CodeExample"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { Form, FormFields } from "#/components/Form/Form"; import { FormField } from "#/components/FormField/FormField"; +import { Label } from "#/components/Label/Label"; import { Link as DocsLink } from "#/components/Link/Link"; import { Spinner } from "#/components/Spinner/Spinner"; import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; @@ -242,6 +244,8 @@ type ProviderFormProps = { editing?: boolean; /** When editing Bedrock and the API already has keys, show masked placeholders until cleared. */ bedrockSavedAccessCredentials?: boolean; + /** Server-generated STS external ID, shown read-only when a role is assumed. */ + bedrockExternalId?: string; /** When editing openai/anthropic and a key is on file, show a masked placeholder until cleared. */ openAiAnthropicSavedApiKey?: boolean; /** Masked rendering of the saved openai/anthropic key (e.g. `sk-***...ABCD`). Falls back to a generic mask when omitted. */ @@ -271,6 +275,7 @@ const baseUrlPlaceholder = (provider: string) => export const ProviderForm: FC = ({ editing = false, bedrockSavedAccessCredentials = false, + bedrockExternalId, openAiAnthropicSavedApiKey = false, openAiAnthropicMaskedApiKey, initialValues, @@ -538,6 +543,17 @@ export const ProviderForm: FC = ({ Optional. When a role ARN is set, the gateway assumes that role (using the base identity) before calling Bedrock.

+ {editing && bedrockExternalId && ( +
+ + +

+ Server-generated. Add it to the assumed role's trust policy as + an sts:ExternalId condition so only this + deployment can assume the role. +

+
+ )} )} diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts index 43f1c767624..ab71d513590 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts @@ -13,6 +13,7 @@ import { } from "./ProviderForm"; import { aiProviderToFormValues, + bedrockExternalId, getProviderDisplayType, hasBedrockStoredCredentials, isBedrockProvider, @@ -189,6 +190,36 @@ describe("hasBedrockStoredCredentials", () => { }); }); +describe("bedrockExternalId", () => { + it("returns the external ID from a role-based Bedrock provider", () => { + const provider: AIProvider = { + ...MockAIProviderBedrock, + settings: settings({ + _type: "bedrock", + role_arn: "arn:aws:iam::123456789012:role/BedrockRole", + external_id: "7QF3ZK2MLP4RS6TUVWXY2ABCDE", + }), + }; + expect(bedrockExternalId(provider)).toBe("7QF3ZK2MLP4RS6TUVWXY2ABCDE"); + }); + + it("returns undefined when the provider has no external ID", () => { + expect(bedrockExternalId(MockAIProviderBedrock)).toBeUndefined(); + }); + + it("returns undefined when the external ID is an empty string", () => { + const provider: AIProvider = { + ...MockAIProviderBedrock, + settings: settings({ _type: "bedrock", external_id: "" }), + }; + expect(bedrockExternalId(provider)).toBeUndefined(); + }); + + it("returns undefined for a non-Bedrock provider", () => { + expect(bedrockExternalId(MockAIProviderOpenAI)).toBeUndefined(); + }); +}); + describe("getProviderDisplayType", () => { it("returns bedrock for a Bedrock provider", () => { expect(getProviderDisplayType(MockAIProviderBedrock)).toBe("bedrock"); diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts index 92df168a449..acb32c19753 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts @@ -55,6 +55,17 @@ export const isBedrockProvider = (provider: AIProvider): boolean => { return s !== null && s._type === BEDROCK_SETTINGS_TYPE; }; +// The external ID is server-generated and returned on read when the Bedrock +// provider assumes a role. It is read-only: the form displays it but never +// submits it. +export const bedrockExternalId = (provider: AIProvider): string | undefined => { + if (!isBedrockProvider(provider)) { + return undefined; + } + const s = provider.settings as SettingsWire | null; + return s?.external_id || undefined; +}; + export const hasBedrockStoredCredentials = (provider: AIProvider): boolean => { if (!isBedrockProvider(provider)) { return false; From d70b941f74b07c00ef6115fa9c71ea64a673c22c Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 2 Jul 2026 04:37:34 +0000 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20fix(site/src/pages/AISetting?= =?UTF-8?q?sPage/ProvidersPage):=20address=20Bedrock=20external=20ID=20rev?= =?UTF-8?q?iew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add EditBedrockWithExternalId Storybook story so the read-only external ID block on the Bedrock edit form has coverage (CRF-1). - Trim the bedrockExternalId comment to describe the function's own contract instead of restating the generated JSDoc and consumer behavior (CRF-2). Modified by Coder Agents on behalf of Jake Howell. --- .../components/ProviderForm.stories.tsx | 34 +++++++++++++++++++ .../components/providerFormApiMap.ts | 4 +-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index 7851332862c..af492374278 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -293,6 +293,40 @@ export const EditBedrockKeepCredentials: Story = { }, }; +// The external ID is server-generated when a role is assumed. The edit form +// surfaces it read-only next to Role ARN so operators can add the value to +// their role's trust policy as an sts:ExternalId condition. +export const EditBedrockWithExternalId: Story = { + args: { + editing: true, + bedrockSavedAccessCredentials: true, + bedrockExternalId: "7QF3ZK2MLP4RS6TUVWXY2ABCDE", + initialValues: { + type: "bedrock", + name: "bedrock", + displayName: "Bedrock", + baseUrl: "https://bedrock-runtime.us-east-2.amazonaws.com", + model: "anthropic.claude-opus-4-7", + smallFastModel: "anthropic.claude-haiku-4-5", + accessKey: "", + accessKeySecret: "", + roleArn: "arn:aws:iam::123456789012:role/BedrockRole", + enabled: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.findByText("External ID")).resolves.toBeVisible(); + await expect( + canvas.findByText("7QF3ZK2MLP4RS6TUVWXY2ABCDE"), + ).resolves.toBeVisible(); + await expect(canvas.findByText("sts:ExternalId")).resolves.toBeVisible(); + await expect( + canvas.findByRole("button", { name: /copy code/i }), + ).resolves.toBeVisible(); + }, +}; + export const AddCopilot: Story = { args: { // The real add flow passes only the type; the form fills name and diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts index acb32c19753..71ca37dcbef 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts @@ -55,9 +55,7 @@ export const isBedrockProvider = (provider: AIProvider): boolean => { return s !== null && s._type === BEDROCK_SETTINGS_TYPE; }; -// The external ID is server-generated and returned on read when the Bedrock -// provider assumes a role. It is read-only: the form displays it but never -// submits it. +// Server-generated STS external ID; read-only. export const bedrockExternalId = (provider: AIProvider): string | undefined => { if (!isBedrockProvider(provider)) { return undefined;