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.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/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..71ca37dcbef 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;