Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ProviderForm } from "../components/ProviderForm";
import { getProviderIcon } from "../components/ProviderIcon";
import {
aiProviderToFormValues,
bedrockExternalId,
getProviderDisplayType,
hasBedrockStoredCredentials,
isBedrockProvider,
Expand Down Expand Up @@ -196,6 +197,7 @@ const UpdateProviderPageView: React.FC = () => {
bedrockSavedAccessCredentials={hasBedrockStoredCredentials(
provider,
)}
bedrockExternalId={bedrockExternalId(provider)}
openAiAnthropicSavedApiKey={openAiAnthropicSavedApiKey}
openAiAnthropicMaskedApiKey={openAiAnthropicMaskedApiKey}
initialValues={aiProviderToFormValues(provider)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -271,6 +275,7 @@ const baseUrlPlaceholder = (provider: string) =>
export const ProviderForm: FC<ProviderFormProps> = ({
editing = false,
bedrockSavedAccessCredentials = false,
bedrockExternalId,
openAiAnthropicSavedApiKey = false,
openAiAnthropicMaskedApiKey,
initialValues,
Expand Down Expand Up @@ -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 && (
Comment thread
jakehwll marked this conversation as resolved.
<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>
)}
</>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "./ProviderForm";
import {
aiProviderToFormValues,
bedrockExternalId,
getProviderDisplayType,
hasBedrockStoredCredentials,
isBedrockProvider,
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-4] External ID lifecycle outlives the role ARN it was generated from.

"The server preserves the external ID after role removal (ensureBedrockExternalID only generates, never clears; the merge function carries it forward). The UI gate is editing && bedrockExternalId, with no check on whether role_arn is currently set." (Hisoka)

If an admin once set a role ARN, then cleared it, the external ID persists server-side. The UI renders "Add it to the assumed role's trust policy" next to an empty Role ARN field. Not a bug per se: the external ID is valid for future role configurations, and the text is forward-looking. Worth knowing.

🤖

};

export const hasBedrockStoredCredentials = (provider: AIProvider): boolean => {
if (!isBedrockProvider(provider)) {
return false;
Expand Down
Loading