Skip to content
Closed
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
24 changes: 22 additions & 2 deletions src/components/HarnessWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
HarnessTool,
UpdateHarnessRequest,
} from "@aws-sdk/client-bedrock-agentcore-control";
import type { CreateHarnessInput } from "../handlers/harness/types";
import type { CreateHarnessInput, HarnessRolePolicyWarning } from "../handlers/harness/types";
import type { ScreenProps } from "../handlers/types";
import { coreOptsFromCtx } from "../handlers/utils";
import { Layout } from "./Layout";
Expand Down Expand Up @@ -241,6 +241,7 @@ export interface HarnessWizardProps extends ScreenProps {
harnessId?: string;
// initial seeds the form (update mode: the current configuration).
initial?: HarnessFormValues;
rolePolicyWarning?: HarnessRolePolicyWarning;
// onDone is called after a successful submit is acknowledged.
onDone: (harnessId: string) => void;
}
Expand All @@ -258,6 +259,7 @@ export function HarnessWizard({
breadcrumb,
harnessId,
initial,
rolePolicyWarning,
onDone,
}: HarnessWizardProps) {
const navigate = useNavigate();
Expand Down Expand Up @@ -354,6 +356,7 @@ export function HarnessWizard({
values={values}
patch={patch}
request={request}
rolePolicyWarning={rolePolicyWarning}
onNext={next}
onBack={back}
onSubmit={submit}
Expand Down Expand Up @@ -430,6 +433,7 @@ interface WizardStepProps {
values: HarnessFormValues;
patch: (update: Partial<HarnessFormValues>) => void;
request: unknown;
rolePolicyWarning?: HarnessRolePolicyWarning;
onNext: () => void;
onBack: () => void;
onSubmit: () => void;
Expand All @@ -441,6 +445,7 @@ function WizardStep({
values,
patch,
request,
rolePolicyWarning,
onNext,
onBack,
onSubmit,
Expand Down Expand Up @@ -493,7 +498,15 @@ function WizardStep({
/>
);
case "review":
return <ReviewStep mode={mode} request={request} onSubmit={onSubmit} onBack={onBack} />;
return (
<ReviewStep
mode={mode}
request={request}
rolePolicyWarning={rolePolicyWarning}
onSubmit={onSubmit}
onBack={onBack}
/>
);
default:
return null;
}
Expand Down Expand Up @@ -1099,11 +1112,13 @@ function PromptStep({
function ReviewStep({
mode,
request,
rolePolicyWarning,
onSubmit,
onBack,
}: {
mode: "create" | "update";
request: unknown;
rolePolicyWarning?: HarnessRolePolicyWarning;
onSubmit: () => void;
onBack: () => void;
}) {
Expand All @@ -1124,6 +1139,11 @@ function ReviewStep({
? "this request will be sent to CreateHarness"
: "only the changed fields are sent to UpdateHarness"}
</Text>
{rolePolicyWarning && (
<Text color={theme.colors.warning}>
{`Execution role ${rolePolicyWarning.roleArn} is not managed for this Harness. IAM policies will not be modified; you are responsible for permissions required by this update.`}
</Text>
)}
{/* The step body is inset by paddingX on both sides. */}
<Divider width={columns - 2} />
<ScrollView>
Expand Down
68 changes: 68 additions & 0 deletions src/core/credentialProviderPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import {
GetApiKeyCredentialProviderCommand,
GetOauth2CredentialProviderCommand,
type BedrockAgentCoreControlClient,
} from "@aws-sdk/client-bedrock-agentcore-control";
import { CredentialProviderPolicyResolver } from "./credentialProviderPolicy";

const API_KEY_ARN =
"arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/openai";
const OAUTH_ARN =
"arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/gateway";

describe("CredentialProviderPolicyResolver", () => {
test("resolves each typed provider to its exact backing secret", async () => {
const control = {
send: async (
command: GetApiKeyCredentialProviderCommand | GetOauth2CredentialProviderCommand,
) => {
if (command instanceof GetApiKeyCredentialProviderCommand) {
return {
credentialProviderArn: API_KEY_ARN,
apiKeySecretArn: {
secretArn: "arn:aws:secretsmanager:us-west-2:123456789012:secret:openai",
},
};
}
return {
credentialProviderArn: OAUTH_ARN,
clientSecretArn: {
secretArn: "arn:aws:secretsmanager:us-west-2:123456789012:secret:gateway",
},
};
},
} as unknown as BedrockAgentCoreControlClient;

await expect(
new CredentialProviderPolicyResolver(control).resolve([
{ type: "oauth", providerArn: OAUTH_ARN },
{ type: "api-key", providerArn: API_KEY_ARN },
{ type: "api-key", providerArn: API_KEY_ARN },
]),
).resolves.toEqual([
{
providerArn: API_KEY_ARN,
secretArn: "arn:aws:secretsmanager:us-west-2:123456789012:secret:openai",
},
{
providerArn: OAUTH_ARN,
secretArn: "arn:aws:secretsmanager:us-west-2:123456789012:secret:gateway",
},
]);
});

test("rejects a provider ARN whose kind contradicts its configuration", async () => {
const control = {
send: async () => {
throw new Error("mismatch must fail before the service read");
},
} as unknown as BedrockAgentCoreControlClient;

await expect(
new CredentialProviderPolicyResolver(control).resolve([
{ type: "api-key", providerArn: OAUTH_ARN },
]),
).rejects.toThrow(/configured as api-key.*identifies an oauth provider/);
});
});
90 changes: 90 additions & 0 deletions src/core/credentialProviderPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
GetApiKeyCredentialProviderCommand,
GetOauth2CredentialProviderCommand,
type BedrockAgentCoreControlClient,
} from "@aws-sdk/client-bedrock-agentcore-control";

export type CredentialProviderPolicyState = {
providerArn: string;
secretArn: string;
};

export type CredentialProviderPolicyRequest = {
type: "api-key" | "oauth";
providerArn: string;
};

export class CredentialProviderPolicyResolver {
constructor(private readonly control: BedrockAgentCoreControlClient) {}

async resolve(
requests: readonly CredentialProviderPolicyRequest[],
): Promise<CredentialProviderPolicyState[]> {
const requestedTypes = new Map<string, CredentialProviderPolicyRequest["type"]>();
for (const request of requests) {
const existing = requestedTypes.get(request.providerArn);
if (existing && existing !== request.type) {
throw new Error(`Credential provider ${request.providerArn} is configured as two types.`);
}
requestedTypes.set(request.providerArn, request.type);
}

const providers: CredentialProviderPolicyState[] = [];
for (const [providerArn, requestedType] of [...requestedTypes].sort(([left], [right]) =>
left.localeCompare(right),
)) {
const identity = credentialProviderIdentity(providerArn);
if (identity.type !== requestedType) {
throw new Error(
`Credential provider ${providerArn} is configured as ${requestedType} but identifies an ${identity.type} provider.`,
);
}
if (identity.type === "api-key") {
const response = await this.control.send(
new GetApiKeyCredentialProviderCommand({ name: identity.name }),
);
if (response.credentialProviderArn !== providerArn) {
throw new Error(
`API key credential provider ${identity.name} returned an unexpected ARN.`,
);
}
const secretArn = response.apiKeySecretArn?.secretArn;
if (!secretArn) {
throw new Error(`API key credential provider ${providerArn} returned no secret ARN.`);
}
providers.push({ providerArn, secretArn });
continue;
}

const response = await this.control.send(
new GetOauth2CredentialProviderCommand({ name: identity.name }),
);
if (response.credentialProviderArn !== providerArn) {
throw new Error(`OAuth credential provider ${identity.name} returned an unexpected ARN.`);
}
const secretArn = response.clientSecretArn?.secretArn;
if (!secretArn) {
throw new Error(`OAuth credential provider ${providerArn} returned no secret ARN.`);
}
providers.push({ providerArn, secretArn });
}
return providers;
}
}

function credentialProviderIdentity(providerArn: string): {
type: "api-key" | "oauth";
name: string;
} {
const resource = providerArn.split(":").slice(5).join(":");
const match = resource.match(
/^token-vault\/[^/]+\/(apikeycredentialprovider|oauth2credentialprovider)\/([^/]+)$/,
);
if (!match?.[1] || !match[2]) {
throw new Error(`Invalid credential provider ARN "${providerArn}".`);
}
return {
type: match[1] === "apikeycredentialprovider" ? "api-key" : "oauth",
name: match[2],
};
}
Loading
Loading