From dee41c34e6a2642e37419141fd65d9c4b7854829 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 2 Jul 2026 10:55:50 -0400 Subject: [PATCH] feat: show Bedrock external ID in the provider edit form (#26919) Surfaces the server-generated STS external ID on the Bedrock provider edit form. When a provider assumes a role, the form shows the external ID read-only with a copy icon and a short note to add it to the target role's trust policy as an sts:ExternalId condition. The value is display-only: it is passed to the form as its own prop rather than as an editable form value, so it is never submitted back. This matches the backend contract, where the external ID is server-owned and a changed value is rejected. Builds on the backend in #26869. Follow-up to #26578. --------- Co-authored-by: Jake Howell --- .../UpdateProviderPageView.tsx | 2 ++ .../components/ProviderForm.stories.tsx | 34 +++++++++++++++++++ .../ProvidersPage/components/ProviderForm.tsx | 16 +++++++++ .../components/providerFormApiMap.test.ts | 31 +++++++++++++++++ .../components/providerFormApiMap.ts | 9 +++++ 5 files changed, 92 insertions(+) diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx index dd4dd96599..97ed87b975 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.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index 7851332862..af49237427 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/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index 6a2b1dfc4a..7a87b06d14 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 43f1c76762..ab71d51359 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 92df168a44..71ca37dcbe 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts @@ -55,6 +55,15 @@ export const isBedrockProvider = (provider: AIProvider): boolean => { return s !== null && s._type === BEDROCK_SETTINGS_TYPE; }; +// Server-generated STS external ID; read-only. +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;