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.
+