From 7482cffe7cc9780286fb793a1643a20f32f576ac Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 6 Jul 2026 06:32:12 +0000 Subject: [PATCH] feat(coderd_ai_provider): expose server-generated Bedrock external_id --- docs/resources/ai_provider.md | 4 + internal/provider/ai_provider_resource.go | 45 +++++++++++ .../provider/ai_provider_resource_test.go | 74 +++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/docs/resources/ai_provider.md b/docs/resources/ai_provider.md index cae72bf..c47bdd9 100644 --- a/docs/resources/ai_provider.md +++ b/docs/resources/ai_provider.md @@ -105,6 +105,10 @@ Optional: - `role_arn` (String) ARN of an AWS IAM role to assume via STS before calling Bedrock. The base identity (the AWS SDK default credential chain or the static credentials) signs the AssumeRole call, and the temporary credentials sign Bedrock requests. Omit to call Bedrock with the base identity directly. Requires Coder v2.35.0 or later. - `small_fast_model` (String) Small/fast Bedrock model identifier used for background tasks. +Read-Only: + +- `external_id` (String) STS external ID the server generates and sends on the AssumeRole call when `role_arn` is set. Reference it in the assumed role's trust policy `sts:ExternalId` condition. Null until `role_arn` is first configured; stable afterwards. Requires Coder v2.36.0 or later. + ## Import Import is supported using the following syntax: diff --git a/internal/provider/ai_provider_resource.go b/internal/provider/ai_provider_resource.go index 3e7cbdb..f95f099 100644 --- a/internal/provider/ai_provider_resource.go +++ b/internal/provider/ai_provider_resource.go @@ -65,6 +65,7 @@ type AIProviderBedrockSettingsModel struct { Model types.String `tfsdk:"model"` SmallFastModel types.String `tfsdk:"small_fast_model"` RoleARN types.String `tfsdk:"role_arn"` + ExternalID types.String `tfsdk:"external_id"` AccessKeyWO types.String `tfsdk:"access_key_wo"` AccessKeySecretWO types.String `tfsdk:"access_key_secret_wo"` CredentialsWOVersion types.Int64 `tfsdk:"credentials_wo_version"` @@ -199,6 +200,13 @@ func (r *AIProviderResource) Schema(ctx context.Context, req resource.SchemaRequ MarkdownDescription: "ARN of an AWS IAM role to assume via STS before calling Bedrock. The base identity (the AWS SDK default credential chain or the static credentials) signs the AssumeRole call, and the temporary credentials sign Bedrock requests. Omit to call Bedrock with the base identity directly. Requires Coder v2.35.0 or later.", Optional: true, }, + "external_id": schema.StringAttribute{ + MarkdownDescription: "STS external ID the server generates and sends on the AssumeRole call when `role_arn` is set. Reference it in the assumed role's trust policy `sts:ExternalId` condition. Null until `role_arn` is first configured; stable afterwards. Requires Coder v2.36.0 or later.", + Computed: true, + PlanModifiers: []planmodifier.String{ + bedrockExternalIDPlanModifier{}, + }, + }, "access_key_wo": schema.StringAttribute{ MarkdownDescription: "AWS access key ID for Bedrock. See [Coder's Amazon Bedrock provider docs](https://coder.com/docs/ai-coder/ai-gateway/providers#amazon-bedrock).", Optional: true, @@ -612,6 +620,7 @@ func (m AIProviderResourceModel) stateFromProvider(provider codersdk.AIProvider) Model: types.StringValue(provider.Settings.Bedrock.Model), SmallFastModel: types.StringValue(provider.Settings.Bedrock.SmallFastModel), RoleARN: stringValueOrNull(provider.Settings.Bedrock.RoleARN), + ExternalID: stringValueOrNull(provider.Settings.Bedrock.ExternalID), AccessKeyWO: types.StringNull(), AccessKeySecretWO: types.StringNull(), }} @@ -692,6 +701,42 @@ func (bedrockRegionPlanModifier) PlanModifyString(ctx context.Context, req planm } } +// bedrockExternalIDPlanModifier plans the server-generated STS external ID: +// created when role_arn is first set, then kept even if role_arn is cleared. +type bedrockExternalIDPlanModifier struct{} + +func (bedrockExternalIDPlanModifier) Description(_ context.Context) string { + return "Preserves the server-generated external ID; plans a new one only when role_arn is first set." +} + +func (m bedrockExternalIDPlanModifier) MarkdownDescription(ctx context.Context) string { + return m.Description(ctx) +} + +func (bedrockExternalIDPlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // Once stored, the ID never changes. + if !req.StateValue.IsNull() { + resp.PlanValue = req.StateValue + return + } + + var roleARN types.String + resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root("settings").AtName("bedrock").AtName("role_arn"), &roleARN)...) + if resp.Diagnostics.HasError() { + return + } + + // Unknown or newly set role_arn: stays unknown, the server decides on apply. + if roleARN.IsUnknown() { + return + } + if !roleARN.IsNull() && roleARN.ValueString() != "" { + return + } + // No role and none stored: no ID. + resp.PlanValue = types.StringNull() +} + func validateAIProviderBaseURL(addError func(path.Path, string, string), attrPath path.Path, raw string) { parsed, err := url.Parse(raw) if err != nil || parsed.Scheme == "" || parsed.Host == "" { diff --git a/internal/provider/ai_provider_resource_test.go b/internal/provider/ai_provider_resource_test.go index d8a67b7..92b5190 100644 --- a/internal/provider/ai_provider_resource_test.go +++ b/internal/provider/ai_provider_resource_test.go @@ -2,6 +2,7 @@ package provider import ( "bytes" + "context" "os" "regexp" "testing" @@ -11,7 +12,12 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/tfversion" @@ -467,6 +473,7 @@ func TestAIProviderCreateRequestBedrockWithoutCredentials(t *testing.T) { Model: types.StringValue("anthropic.claude-3-5-sonnet-20241022-v2:0"), SmallFastModel: types.StringValue("anthropic.claude-3-5-haiku-20241022-v1:0"), RoleARN: types.StringValue("arn:aws:iam::123456789012:role/bedrock-access"), + ExternalID: types.StringValue("stored-external-id"), }}, } // Copy the nested Bedrock value so mutating config doesn't alias plan's @@ -484,6 +491,7 @@ func TestAIProviderCreateRequestBedrockWithoutCredentials(t *testing.T) { require.NotNil(t, req.Settings.Bedrock) require.Equal(t, "us-east-1", req.Settings.Bedrock.Region) require.Equal(t, "arn:aws:iam::123456789012:role/bedrock-access", req.Settings.Bedrock.RoleARN) + require.Empty(t, req.Settings.Bedrock.ExternalID, "external_id is server-generated and must not be sent") require.Nil(t, req.Settings.Bedrock.AccessKey) require.Nil(t, req.Settings.Bedrock.AccessKeySecret) } @@ -660,6 +668,7 @@ func TestAIProviderUpdateRotatesBedrockCredentials(t *testing.T) { BaseURL: state.BaseURL, Settings: &AIProviderSettingsModel{Bedrock: &AIProviderBedrockSettingsModel{ Region: types.StringValue("us-east-1"), + ExternalID: types.StringValue("stored-external-id"), CredentialsWOVersion: types.Int64Value(2), }}, } @@ -681,6 +690,7 @@ func TestAIProviderUpdateRotatesBedrockCredentials(t *testing.T) { require.NotNil(t, patch.Settings.Bedrock.AccessKeySecret) require.Equal(t, "AKIANEWKEY", *patch.Settings.Bedrock.AccessKey) require.Equal(t, "newsecretvalue", *patch.Settings.Bedrock.AccessKeySecret) + require.Empty(t, patch.Settings.Bedrock.ExternalID, "external_id is server-generated and must not be sent") } func TestAIProviderUpdatePreservesBedrockCredentialsWhenVersionRemoved(t *testing.T) { @@ -721,6 +731,70 @@ func TestAIProviderUpdatePreservesBedrockCredentialsWhenVersionRemoved(t *testin require.False(t, diags.HasError(), diags.Errors()) } +// aiProviderPlanWithRoleARN builds a plan with only settings.bedrock.role_arn set. +func aiProviderPlanWithRoleARN(t *testing.T, roleARN tftypes.Value) tfsdk.Plan { + t.Helper() + ctx := context.Background() + schemaResp := &fwresource.SchemaResponse{} + (&AIProviderResource{}).Schema(ctx, fwresource.SchemaRequest{}, schemaResp) + require.Empty(t, schemaResp.Diagnostics) + + objType, ok := schemaResp.Schema.Type().TerraformType(ctx).(tftypes.Object) + require.True(t, ok) + settingsType, ok := objType.AttributeTypes["settings"].(tftypes.Object) + require.True(t, ok) + bedrockType, ok := settingsType.AttributeTypes["bedrock"].(tftypes.Object) + require.True(t, ok) + + bedrockVals := map[string]tftypes.Value{} + for name, attrType := range bedrockType.AttributeTypes { + bedrockVals[name] = tftypes.NewValue(attrType, nil) + } + bedrockVals["role_arn"] = roleARN + rootVals := map[string]tftypes.Value{} + for name, attrType := range objType.AttributeTypes { + rootVals[name] = tftypes.NewValue(attrType, nil) + } + rootVals["settings"] = tftypes.NewValue(settingsType, map[string]tftypes.Value{ + "bedrock": tftypes.NewValue(bedrockType, bedrockVals), + }) + return tfsdk.Plan{Schema: schemaResp.Schema, Raw: tftypes.NewValue(objType, rootVals)} +} + +func TestBedrockExternalIDPlanModifier(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + state types.String + roleARN tftypes.Value + planSeed types.String + want types.String + }{ + "preserves stored value": {types.StringValue("stored-external-id"), tftypes.NewValue(tftypes.String, "arn:aws:iam::123456789012:role/other"), types.StringUnknown(), types.StringValue("stored-external-id")}, + "preserved after role_arn clear": {types.StringValue("stored-external-id"), tftypes.NewValue(tftypes.String, nil), types.StringUnknown(), types.StringValue("stored-external-id")}, + "unknown when role_arn set": {types.StringNull(), tftypes.NewValue(tftypes.String, "arn:aws:iam::123456789012:role/bedrock"), types.StringUnknown(), types.StringUnknown()}, + "unknown when role_arn unknown": {types.StringNull(), tftypes.NewValue(tftypes.String, tftypes.UnknownValue), types.StringUnknown(), types.StringUnknown()}, + "null when role_arn null": {types.StringNull(), tftypes.NewValue(tftypes.String, nil), types.StringUnknown(), types.StringNull()}, + "null when role_arn empty": {types.StringNull(), tftypes.NewValue(tftypes.String, ""), types.StringUnknown(), types.StringNull()}, + "stable null on no-op plan (no external_id support)": {types.StringNull(), tftypes.NewValue(tftypes.String, "arn:aws:iam::123456789012:role/bedrock"), types.StringNull(), types.StringNull()}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + req := planmodifier.StringRequest{ + Path: path.Root("settings").AtName("bedrock").AtName("external_id"), + Plan: aiProviderPlanWithRoleARN(t, tc.roleARN), + ConfigValue: types.StringNull(), + StateValue: tc.state, + PlanValue: tc.planSeed, + } + resp := &planmodifier.StringResponse{PlanValue: req.PlanValue} + bedrockExternalIDPlanModifier{}.PlanModifyString(context.Background(), req, resp) + require.False(t, resp.Diagnostics.HasError(), resp.Diagnostics.Errors()) + require.Equal(t, tc.want, resp.PlanValue) + }) + } +} + func TestParseBedrockRegionFromBaseURL(t *testing.T) { t.Parallel()