mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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 <jake@hwll.me>
This commit is contained in:
co-authored by
Jake Howell
parent
79a74fc817
commit
dee41c34e6
+2
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ProviderFormProps> = ({
|
||||
editing = false,
|
||||
bedrockSavedAccessCredentials = false,
|
||||
bedrockExternalId,
|
||||
openAiAnthropicSavedApiKey = false,
|
||||
openAiAnthropicMaskedApiKey,
|
||||
initialValues,
|
||||
@@ -538,6 +543,17 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
Optional. When a role ARN is set, the gateway assumes that role
|
||||
(using the base identity) before calling Bedrock.
|
||||
</p>
|
||||
{editing && bedrockExternalId && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>External ID</Label>
|
||||
<CodeExample secret={false} code={bedrockExternalId} />
|
||||
<p className="text-xs text-content-secondary m-0">
|
||||
Server-generated. Add it to the assumed role's trust policy as
|
||||
an <code>sts:ExternalId</code> condition so only this
|
||||
deployment can assume the role.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user