From 6e7d1db35511e3e15472c4f0a6a8da9562d4e387 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 14:45:48 +0000 Subject: [PATCH 1/9] feat(gateway): manage execution role policies --- src/core/gateway.test.ts | 245 +++++++++- src/core/gateway.tsx | 421 +++++++++++++++++- src/core/gatewayExecutionRole.test.ts | 51 +++ src/core/gatewayExecutionRole.ts | 176 ++++++++ src/core/gatewayPolicy.test.ts | 312 +++++++++++++ src/core/gatewayPolicy.ts | 353 +++++++++++++++ ...DeleteGatewayCommand.19a4b639326fd0de.json | 4 + ...DeleteGatewayCommand.e015bbd46d1859f1.json | 4 - ...teGatewayRuleCommand.342327fcfcb605c7.json | 4 + ...teGatewayRuleCommand.cee854fd6fa9fd16.json | 4 - ...GatewayTargetCommand.5ecec887ab4a48e6.json | 5 + ...GatewayTargetCommand.94eb9a3262a6cda2.json | 5 - ...GatewayTargetCommand.afcce9d3abeac495.json | 5 - ...GatewayTargetCommand.bbf67dd173942336.json | 5 + .../GetGatewayCommand.19a4b639326fd0de.json | 19 + ...atewayTargetCommand.5ecec887ab4a48e6.json} | 11 +- .../delete/connector-delete.golden.json | 4 +- .../delete/gateway-delete.golden.json | 2 +- .../__fixtures__/delete/resources.json | 10 +- .../delete/rule-delete.golden.json | 2 +- .../delete/target-delete.golden.json | 4 +- .../gateway/connector/create/index.tsx | 5 +- .../gateway/connector/update/index.tsx | 13 +- src/handlers/gateway/create/index.tsx | 13 +- src/handlers/gateway/gateway.create.test.tsx | 5 - src/handlers/gateway/gateway.update.test.tsx | 6 + src/handlers/gateway/rolePolicyWarning.ts | 19 + src/handlers/gateway/target/create/index.tsx | 5 +- src/handlers/gateway/target/update/index.tsx | 13 +- src/handlers/gateway/types.tsx | 6 +- src/handlers/gateway/update/index.tsx | 22 +- src/testing/TestCoreClient.tsx | 8 + 32 files changed, 1674 insertions(+), 87 deletions(-) create mode 100644 src/core/gatewayExecutionRole.test.ts create mode 100644 src/core/gatewayExecutionRole.ts create mode 100644 src/core/gatewayPolicy.test.ts create mode 100644 src/core/gatewayPolicy.ts create mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.19a4b639326fd0de.json delete mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.e015bbd46d1859f1.json create mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.342327fcfcb605c7.json delete mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.cee854fd6fa9fd16.json create mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.5ecec887ab4a48e6.json delete mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.94eb9a3262a6cda2.json delete mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.afcce9d3abeac495.json create mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.bbf67dd173942336.json create mode 100644 src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.19a4b639326fd0de.json rename src/handlers/gateway/__fixtures__/delete/{GetGatewayTargetCommand.94eb9a3262a6cda2.json => GetGatewayTargetCommand.5ecec887ab4a48e6.json} (62%) create mode 100644 src/handlers/gateway/rolePolicyWarning.ts diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index e900fe74e..a2322000d 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -1,8 +1,11 @@ import { describe, expect, mock, test } from "bun:test"; import { + CreateGatewayCommand, + CreateGatewayTargetCommand, DeleteGatewayCommand, DeleteGatewayRuleCommand, DeleteGatewayTargetCommand, + GetApiKeyCredentialProviderCommand, GetGatewayCommand, GetGatewayTargetCommand, ListGatewayTargetsCommand, @@ -14,6 +17,12 @@ import { type GetGatewayTargetResponse, type TargetSummary, } from "@aws-sdk/client-bedrock-agentcore-control"; +import { + CreateRoleCommand, + GetRoleCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; import { ERROR_SOURCE, ResultTruncationError } from "../errors"; import type { GatewayTargetUpdatePatch, GatewayUpdatePatch } from "../handlers/gateway/types"; import type { AwsClients } from "./types"; @@ -229,11 +238,160 @@ describe("GatewayClient Connector facade", () => { const OPTIONS = { region: "us-west-2" }; +test("creates a Gateway execution role when no role ARN is supplied", async () => { + const controlCommands: unknown[] = []; + const iamCommands: unknown[] = []; + const clients = { + control: () => + ({ + send: async (command: unknown) => { + controlCommands.push(command); + return command instanceof CreateGatewayCommand + ? { + gatewayId: "gateway-1", + gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", + } + : { gatewayId: "gateway-1", status: "READY" }; + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (command: GetRoleCommand | CreateRoleCommand) => { + iamCommands.push(command); + if (command instanceof GetRoleCommand) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { + Role: { + RoleName: "AgentCoreCliGateway-orders", + Arn: "arn:aws:iam::123456789012:role/AgentCoreCliGateway-orders", + }, + }; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + + await new GatewayClient(clients, { propagationDelayMs: 0 }).createGateway( + { name: "orders", authorizerType: "NONE" }, + OPTIONS, + ); + + expect(iamCommands[0]).toBeInstanceOf(GetRoleCommand); + expect(iamCommands[1]).toBeInstanceOf(CreateRoleCommand); + expect(controlCommands[0]).toBeInstanceOf(CreateGatewayCommand); + expect((controlCommands[0] as CreateGatewayCommand).input.roleArn).toBe( + "arn:aws:iam::123456789012:role/AgentCoreCliGateway-orders", + ); +}); + +test("stages a Lambda grant without dropping existing target and auth grants", async () => { + const providerArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; + const secretArn = "arn:aws:secretsmanager:us-west-2:123456789012:secret:orders"; + const gatewayResponse = { + ...gateway(), + roleArn: "arn:aws:iam::123456789012:role/AgentCoreCliGateway-orders", + workloadIdentityDetails: { + workloadIdentityArn: + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default/workload-identity/orders", + }, + }; + const existingTarget = { + targetId: "web-search", + targetConfiguration: { + mcp: { connector: { source: { connectorId: "web-search" } } }, + }, + } as GetGatewayTargetResponse; + const authenticatedTarget = { + targetId: "authenticated", + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.test/mcp" } }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { providerArn }, + }, + }, + ], + } as GetGatewayTargetResponse; + const policies: unknown[] = []; + const clients = { + control: () => + ({ + send: async (command: unknown) => { + if (command instanceof GetGatewayCommand) return gatewayResponse; + if (command instanceof ListGatewayTargetsCommand) { + return { + items: [ + { targetId: existingTarget.targetId }, + { targetId: authenticatedTarget.targetId }, + ], + }; + } + if (command instanceof GetGatewayTargetCommand) { + if (command.input.targetId === existingTarget.targetId) return existingTarget; + if (command.input.targetId === authenticatedTarget.targetId) { + return authenticatedTarget; + } + return { targetId: "lambda", status: "READY" }; + } + if (command instanceof GetApiKeyCredentialProviderCommand) { + expect(command.input.name).toBe("orders"); + return { + credentialProviderArn: providerArn, + apiKeySecretArn: { secretArn }, + }; + } + if (command instanceof CreateGatewayTargetCommand) { + expect(JSON.stringify(policies.at(-1))).toContain( + "arn:aws:lambda:us-west-2:123456789012:function:orders", + ); + expect(JSON.stringify(policies.at(-1))).toContain("InvokeWebSearch"); + expect(JSON.stringify(policies.at(-1))).toContain(secretArn); + return { targetId: "lambda" }; + } + throw new Error(`unexpected command ${command}`); + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (command: PutRolePolicyCommand) => { + policies.push(JSON.parse(command.input.PolicyDocument!)); + return {}; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + + await new GatewayClient(clients, { propagationDelayMs: 0 }).createGatewayTarget( + { + gatewayIdentifier: "gateway-1", + name: "lambda", + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: "arn:aws:lambda:us-west-2:123456789012:function:orders", + toolSchema: { inlinePayload: [] }, + }, + }, + }, + }, + OPTIONS, + ); + + expect(policies).toHaveLength(1); +}); + function gateway(): GetGatewayResponse { return { gatewayId: "gateway-1", + gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", name: "orders", roleArn: "arn:aws:iam::123456789012:role/orders", + status: "READY", authorizerType: "CUSTOM_JWT", authorizerConfiguration: { customJWTAuthorizer: { @@ -273,24 +431,26 @@ function target(): GetGatewayTargetResponse { } test("maps Gateway, Target, and Rule selectors to their delete commands", async () => { - const { client, commands } = recordingGatewayClient([{}, {}, {}]); + const { client, commands } = recordingGatewayClient([gateway(), {}, gateway(), {}, {}]); await client.deleteGateway("gateway-1", OPTIONS); await client.deleteGatewayTarget("gateway-1", "target-1", OPTIONS); await client.deleteGatewayRule("gateway-1", "rule-1", OPTIONS); - expect(commands).toHaveLength(3); - expect(commands[0]).toBeInstanceOf(DeleteGatewayCommand); - expect((commands[0] as DeleteGatewayCommand).input).toEqual({ + expect(commands).toHaveLength(5); + expect(commands[0]).toBeInstanceOf(GetGatewayCommand); + expect(commands[1]).toBeInstanceOf(DeleteGatewayCommand); + expect((commands[1] as DeleteGatewayCommand).input).toEqual({ gatewayIdentifier: "gateway-1", }); - expect(commands[1]).toBeInstanceOf(DeleteGatewayTargetCommand); - expect((commands[1] as DeleteGatewayTargetCommand).input).toEqual({ + expect(commands[2]).toBeInstanceOf(GetGatewayCommand); + expect(commands[3]).toBeInstanceOf(DeleteGatewayTargetCommand); + expect((commands[3] as DeleteGatewayTargetCommand).input).toEqual({ gatewayIdentifier: "gateway-1", targetId: "target-1", }); - expect(commands[2]).toBeInstanceOf(DeleteGatewayRuleCommand); - expect((commands[2] as DeleteGatewayRuleCommand).input).toEqual({ + expect(commands[4]).toBeInstanceOf(DeleteGatewayRuleCommand); + expect((commands[4] as DeleteGatewayRuleCommand).input).toEqual({ gatewayIdentifier: "gateway-1", ruleId: "rule-1", }); @@ -339,17 +499,62 @@ async function targetUpdateInput( patch: GatewayTargetUpdatePatch, current: GetGatewayTargetResponse = target(), ): Promise { - const { client, commands } = recordingGatewayClient([current, {}]); + const { client, commands } = recordingGatewayClient([current, gateway(), {}]); await client.updateGatewayTarget(patch, OPTIONS); expect(commands[0]).toBeInstanceOf(GetGatewayTargetCommand); expect((commands[0] as GetGatewayTargetCommand).input).toEqual({ gatewayIdentifier: patch.gatewayId, targetId: patch.targetId, }); - return (commands[1] as UpdateGatewayTargetCommand).input; + return (commands[2] as UpdateGatewayTargetCommand).input; } describe("GatewayClient updateGateway", () => { + test("stages Policy Engine permissions before updating a CLI-owned Gateway", async () => { + const order: string[] = []; + const current = { + ...gateway(), + gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", + roleArn: "arn:aws:iam::123456789012:role/AgentCoreCliGateway-orders", + policyEngineConfiguration: undefined, + }; + const clients = { + control: () => + ({ + send: async (command: GetGatewayCommand | UpdateGatewayCommand) => { + if (command instanceof GetGatewayCommand) { + order.push("get"); + return current; + } + if (command instanceof ListGatewayTargetsCommand) return { items: [] }; + order.push("update"); + return { gatewayId: "gateway-1" }; + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (_command: PutRolePolicyCommand) => { + order.push("policy"); + return {}; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + const client = new GatewayClient(clients, { propagationDelayMs: 0 }); + + await client.updateGateway( + { + id: "gateway-1", + policyEngineConfiguration: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/engine-2", + mode: "ENFORCE", + }, + }, + OPTIONS, + ); + + expect(order).toEqual(["get", "policy", "update", "get"]); + }); + test("clears requested fields and merges a Policy Engine mode change", async () => { expect( await gatewayUpdateInput({ @@ -451,6 +656,21 @@ describe("GatewayClient updateGatewayTarget", () => { }); }); + test("does not echo empty service metadata arrays into an update", async () => { + const input = await targetUpdateInput( + { + gatewayId: "gateway-1", + targetId: "target-1", + description: "after", + }, + { + ...target(), + metadataConfiguration: { allowedRequestHeaders: [] }, + }, + ); + expect(input.metadataConfiguration).toBeUndefined(); + }); + test("rejects endpoint shorthand for a non-MCP-server Target", async () => { const { client } = recordingGatewayClient([ { @@ -485,6 +705,7 @@ describe("GatewayClient updateGatewayConnector", () => { }; const { client, commands } = recordingGatewayClient([ { targetId: "target-1", targetConfiguration } as GetGatewayTargetResponse, + gateway(), {}, ]); @@ -497,8 +718,8 @@ describe("GatewayClient updateGatewayConnector", () => { OPTIONS, ); - expect(commands[1]).toBeInstanceOf(UpdateGatewayTargetCommand); - expect((commands[1] as UpdateGatewayTargetCommand).input.targetConfiguration).toEqual( + expect(commands[2]).toBeInstanceOf(UpdateGatewayTargetCommand); + expect((commands[2] as UpdateGatewayTargetCommand).input.targetConfiguration).toEqual( targetConfiguration, ); }); diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 5a3812bef..effb1efe7 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -5,9 +5,11 @@ import { DeleteGatewayCommand, DeleteGatewayRuleCommand, DeleteGatewayTargetCommand, + GetApiKeyCredentialProviderCommand, GetGatewayCommand, GetGatewayRuleCommand, GetGatewayTargetCommand, + GetOauth2CredentialProviderCommand, ListGatewayRulesCommand, ListGatewaysCommand, ListGatewayTargetsCommand, @@ -51,13 +53,39 @@ import type { GatewayUpdatePatch, } from "../handlers/gateway/types"; import type { AwsClients, CoreOptions } from "./types"; +import { + GatewayExecutionRole, + isGatewayExecutionRole, + type GatewayExecutionRoleOptions, +} from "./gatewayExecutionRole"; +import { gatewayPolicy, type GatewayPolicyStatement } from "./gatewayPolicy"; import { toClientConfig } from "./utils"; const DEFAULT_CONNECTOR_PAGE_SIZE = 100; const MAX_CONNECTOR_TARGET_PAGES = 101; +const DEFAULT_WAIT_ATTEMPTS = 150; +const DEFAULT_WAIT_DELAY_MS = 2_000; + +type GatewayClientOptions = GatewayExecutionRoleOptions & { + waitAttempts?: number; + waitDelayMs?: number; +}; export class GatewayClient implements CoreGatewayClient { - constructor(private readonly clients: AwsClients) {} + constructor( + private readonly clients: AwsClients, + private readonly roleOptions: GatewayClientOptions = {}, + ) {} + + async getGatewayRolePolicyWarning( + gatewayId: string, + options: CoreOptions, + ): Promise { + const gateway = await this.getGateway(gatewayId, options); + const name = GatewayClient.required(gateway.name, "Gateway", "name"); + const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); + return isGatewayExecutionRole(name, roleArn) ? undefined : roleArn; + } async createGateway( input: CreateGatewayInput, @@ -65,13 +93,50 @@ export class GatewayClient implements CoreGatewayClient { ): Promise { const control = this.clients.control(toClientConfig(options)); const { protocol, roleArn, ...request } = input; - return control.send( - new CreateGatewayCommand({ - ...request, - roleArn, - ...(protocol === "mcp" ? { protocolType: "MCP" as const } : {}), + const operation = (executionRoleArn: string) => + control.send( + new CreateGatewayCommand({ + ...request, + roleArn: executionRoleArn, + ...(protocol === "mcp" ? { protocolType: "MCP" as const } : {}), + }), + ); + if (roleArn) return operation(roleArn); + const roleManager = this.executionRole(options); + const role = await roleManager.ensure(request.name!); + let response: CreateGatewayResponse; + try { + const staged = gatewayPolicy({ + policyEngineArn: request.policyEngineConfiguration?.arn, + interceptorConfigurations: request.interceptorConfigurations, + }); + const current = role.created ? [] : await roleManager.read(role.arn); + response = await roleManager.update( + role.arn, + current, + staged, + async () => { + const created = await operation(role.arn); + const gatewayId = GatewayClient.required(created.gatewayId, "Created Gateway", "ID"); + await this.waitForGateway(gatewayId, options); + return created; + }, + { forcePropagation: role.created }, + ); + } catch (error) { + await roleManager.rollbackCreate(role); + throw error; + } + const gatewayArn = GatewayClient.required(response.gatewayArn, "Created Gateway", "ARN"); + await roleManager.replace( + role.arn, + gatewayPolicy({ + gatewayArn, + policyEngineArn: request.policyEngineConfiguration?.arn, + interceptorConfigurations: request.interceptorConfigurations, }), ); + return response; } async getGateway(id: string, options: CoreOptions): Promise { @@ -148,7 +213,44 @@ export class GatewayClient implements CoreGatewayClient { exceptionLevel, wafConfiguration, }; - return control.send(new UpdateGatewayCommand(request)); + const operation = () => control.send(new UpdateGatewayCommand(request)); + if (patch.roleArn) { + const response = await operation(); + if (patch.roleArn !== roleArn && isGatewayExecutionRole(name, roleArn)) { + await this.waitForGateway(patch.id, options); + await this.executionRole(options).replace(roleArn, []); + } + return response; + } + if ( + patch.skipRolePolicyUpdate || + (patch.policyEngineConfiguration === undefined && + patch.interceptorConfigurations === undefined && + patch.customTransformConfiguration === undefined) + ) { + return operation(); + } + + if (!isGatewayExecutionRole(name, roleArn)) return operation(); + const roleManager = this.executionRole(options); + const targets = await this.targetInventory(patch.id, options); + const credentialSecrets = await this.credentialSecrets(targets, options); + const currentPolicy = this.policy(current, targets, credentialSecrets); + const desiredPolicy = this.policy( + { + ...current, + policyEngineConfiguration, + interceptorConfigurations, + customTransformConfiguration, + }, + targets, + credentialSecrets, + ); + return roleManager.update(roleArn, currentPolicy, desiredPolicy, async () => { + const response = await operation(); + await this.waitForGateway(patch.id, options); + return response; + }); } async listGateways( @@ -162,9 +264,17 @@ export class GatewayClient implements CoreGatewayClient { } async deleteGateway(id: string, options: CoreOptions): Promise { - return this.clients - .control(toClientConfig(options)) - .send(new DeleteGatewayCommand({ gatewayIdentifier: id })); + const control = this.clients.control(toClientConfig(options)); + const operation = () => control.send(new DeleteGatewayCommand({ gatewayIdentifier: id })); + const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: id })); + const name = GatewayClient.required(gateway.name, "Gateway", "name"); + const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); + if (!isGatewayExecutionRole(name, roleArn)) return operation(); + + const response = await operation(); + await this.waitForGatewayDeletion(id, options); + await this.executionRole(options).replace(roleArn, []); + return response; } async getGatewayTarget( @@ -199,9 +309,32 @@ export class GatewayClient implements CoreGatewayClient { input: CreateGatewayTargetInput, options: CoreOptions, ): Promise { - return this.clients - .control(toClientConfig(options)) - .send(new CreateGatewayTargetCommand(input)); + const control = this.clients.control(toClientConfig(options)); + const operation = () => control.send(new CreateGatewayTargetCommand(input)); + const gateway = await control.send( + new GetGatewayCommand({ gatewayIdentifier: input.gatewayIdentifier }), + ); + const name = GatewayClient.required(gateway.name, "Gateway", "name"); + const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); + if (!isGatewayExecutionRole(name, roleArn)) return operation(); + + const targets = await this.targetInventory(input.gatewayIdentifier!, options); + const desiredTargets = [ + ...targets, + { + targetConfiguration: input.targetConfiguration, + credentialProviderConfigurations: input.credentialProviderConfigurations, + }, + ]; + const credentialSecrets = await this.credentialSecrets(desiredTargets, options); + const current = this.policy(gateway, targets, credentialSecrets); + const desired = this.policy(gateway, desiredTargets, credentialSecrets); + return this.executionRole(options).update(roleArn, current, desired, async () => { + const response = await operation(); + const targetId = GatewayClient.required(response.targetId, "Created Gateway Target", "ID"); + await this.waitForGatewayTarget(input.gatewayIdentifier!, targetId, options); + return response; + }); } async getGatewayConnector( @@ -272,12 +405,23 @@ export class GatewayClient implements CoreGatewayClient { targetId: string, options: CoreOptions, ): Promise { - return this.clients.control(toClientConfig(options)).send( - new DeleteGatewayTargetCommand({ - gatewayIdentifier: gatewayId, - targetId, - }), + const control = this.clients.control(toClientConfig(options)); + const request = { gatewayIdentifier: gatewayId, targetId }; + const operation = () => control.send(new DeleteGatewayTargetCommand(request)); + const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: gatewayId })); + const name = GatewayClient.required(gateway.name, "Gateway", "name"); + const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); + if (!isGatewayExecutionRole(name, roleArn)) return operation(); + + const response = await operation(); + await this.waitForGatewayTargetDeletion(gatewayId, targetId, options); + const remaining = await this.targetInventory(gatewayId, options); + const credentialSecrets = await this.credentialSecrets(remaining, options); + await this.executionRole(options).replace( + roleArn, + this.policy(gateway, remaining, credentialSecrets), ); + return response; } async getGatewayRule( @@ -379,9 +523,8 @@ export class GatewayClient implements CoreGatewayClient { current.credentialProviderConfigurations, patch.credentialProviderConfigurations, ); - const metadataConfiguration = GatewayClient.replace( - current.metadataConfiguration, - patch.metadataConfiguration, + const metadataConfiguration = GatewayClient.nonEmptyMetadata( + GatewayClient.replace(current.metadataConfiguration, patch.metadataConfiguration), ); const privateEndpoint = GatewayClient.replace(current.privateEndpoint, patch.privateEndpoint); const request: UpdateGatewayTargetRequest = { @@ -399,7 +542,30 @@ export class GatewayClient implements CoreGatewayClient { "Connector updates require an MCP or inference connector Target configuration", ); } - return control.send(new UpdateGatewayTargetCommand(request)); + const operation = () => control.send(new UpdateGatewayTargetCommand(request)); + if (patch.skipRolePolicyUpdate) return operation(); + const gateway = await control.send( + new GetGatewayCommand({ gatewayIdentifier: patch.gatewayId }), + ); + const gatewayName = GatewayClient.required(gateway.name, "Gateway", "name"); + const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); + if (!isGatewayExecutionRole(gatewayName, roleArn)) return operation(); + + const targets = await this.targetInventory(patch.gatewayId, options, current); + const desiredTargets = targets.map((target) => + target.targetId === patch.targetId ? { ...target, targetConfiguration } : target, + ); + const credentialSecrets = await this.credentialSecrets( + [...targets, ...desiredTargets], + options, + ); + const currentPolicy = this.policy(gateway, targets, credentialSecrets); + const desiredPolicy = this.policy(gateway, desiredTargets, credentialSecrets); + return this.executionRole(options).update(roleArn, currentPolicy, desiredPolicy, async () => { + const response = await operation(); + await this.waitForGatewayTarget(patch.gatewayId, patch.targetId, options); + return response; + }); } private static replace( @@ -410,6 +576,209 @@ export class GatewayClient implements CoreGatewayClient { return replacement === null ? undefined : replacement; } + private static nonEmptyMetadata( + configuration: UpdateGatewayTargetRequest["metadataConfiguration"], + ): UpdateGatewayTargetRequest["metadataConfiguration"] { + if ( + configuration && + Object.values(configuration).some((values) => values && values.length > 0) + ) { + return configuration; + } + return undefined; + } + + private executionRole(options: CoreOptions): GatewayExecutionRole { + return new GatewayExecutionRole(this.clients.iam({ region: options.region }), this.roleOptions); + } + + private policy( + gateway: GetGatewayResponse, + targets: readonly Pick< + GetGatewayTargetResponse, + "targetConfiguration" | "credentialProviderConfigurations" + >[], + credentialSecrets: ReadonlyMap = new Map(), + ): GatewayPolicyStatement[] { + return gatewayPolicy({ + gatewayArn: GatewayClient.required(gateway.gatewayArn, "Gateway", "ARN"), + workloadIdentityArn: gateway.workloadIdentityDetails?.workloadIdentityArn, + policyEngineArn: gateway.policyEngineConfiguration?.arn, + interceptorConfigurations: gateway.interceptorConfigurations, + customTransformConfiguration: gateway.customTransformConfiguration, + credentialSecrets, + targets: targets.map((target) => ({ + targetConfiguration: GatewayClient.required( + target.targetConfiguration, + "Gateway Target", + "configuration", + ), + credentialProviderConfigurations: target.credentialProviderConfigurations, + })), + }); + } + + private async credentialSecrets( + targets: readonly Pick[], + options: CoreOptions, + ): Promise> { + const providers = new Map(); + for (const target of targets) { + for (const configuration of target.credentialProviderConfigurations ?? []) { + const kind = + configuration.credentialProviderType === "API_KEY" + ? "api-key" + : configuration.credentialProviderType === "OAUTH" + ? "oauth" + : undefined; + if (!kind) continue; + const providerArn = + kind === "api-key" + ? configuration.credentialProvider?.apiKeyCredentialProvider?.providerArn + : configuration.credentialProvider?.oauthCredentialProvider?.providerArn; + if (!providerArn) throw new Error(`${configuration.credentialProviderType} ARN is missing`); + if (providers.get(providerArn) && providers.get(providerArn) !== kind) { + throw new Error(`Credential provider ${providerArn} is used as two provider types`); + } + providers.set(providerArn, kind); + } + } + + const control = this.clients.control(toClientConfig(options)); + const secrets = new Map(); + for (const [providerArn, kind] of providers) { + const name = credentialProviderName(providerArn, kind); + const response = + kind === "api-key" + ? await control.send(new GetApiKeyCredentialProviderCommand({ name })) + : await control.send(new GetOauth2CredentialProviderCommand({ name })); + if (response.credentialProviderArn !== providerArn) { + throw new Error(`Credential provider ${name} returned an unexpected ARN`); + } + const secretArn = + "apiKeySecretArn" in response + ? response.apiKeySecretArn?.secretArn + : response.clientSecretArn?.secretArn; + if (!secretArn) throw new Error(`Credential provider ${providerArn} returned no secret ARN`); + secrets.set(providerArn, secretArn); + } + return secrets; + } + + private async targetInventory( + gatewayId: string, + options: CoreOptions, + known?: GetGatewayTargetResponse, + ): Promise { + const targets: GetGatewayTargetResponse[] = []; + let nextToken: string | undefined; + for (let page = 0; page < MAX_CONNECTOR_TARGET_PAGES; page++) { + const response = await this.listGatewayTargets( + gatewayId, + nextToken, + DEFAULT_CONNECTOR_PAGE_SIZE, + options, + ); + for (const summary of response.items ?? []) { + const targetId = GatewayClient.required(summary.targetId, "Gateway Target", "ID"); + targets.push( + known?.targetId === targetId + ? known + : await this.getGatewayTarget(gatewayId, targetId, options), + ); + } + if (!response.nextToken) return targets; + nextToken = response.nextToken; + } + throw new ResultTruncationError( + `Gateway Target discovery exceeded ${MAX_CONNECTOR_TARGET_PAGES} pages; policy inventory is incomplete`, + ); + } + + private async waitForGateway(gatewayId: string, options: CoreOptions): Promise { + await this.waitForTerminal( + `Gateway "${gatewayId}"`, + () => this.getGateway(gatewayId, options), + ["READY"], + ["FAILED", "UPDATE_UNSUCCESSFUL"], + ); + } + + private async waitForGatewayTarget( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise { + await this.waitForTerminal( + `Gateway Target "${targetId}"`, + () => this.getGatewayTarget(gatewayId, targetId, options), + ["READY", "CREATE_PENDING_AUTH", "UPDATE_PENDING_AUTH", "SYNCHRONIZE_PENDING_AUTH"], + ["FAILED", "UPDATE_UNSUCCESSFUL", "SYNCHRONIZE_UNSUCCESSFUL"], + ); + } + + private async waitForGatewayTargetDeletion( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise { + await this.waitForTerminal( + `Gateway Target "${targetId}"`, + () => this.getGatewayTarget(gatewayId, targetId, options), + [], + ["FAILED", "UPDATE_UNSUCCESSFUL", "SYNCHRONIZE_UNSUCCESSFUL"], + true, + ); + } + + private async waitForGatewayDeletion(gatewayId: string, options: CoreOptions): Promise { + await this.waitForTerminal( + `Gateway "${gatewayId}"`, + () => this.getGateway(gatewayId, options), + [], + ["FAILED", "UPDATE_UNSUCCESSFUL"], + true, + ); + } + + private async waitForTerminal( + resource: string, + read: () => Promise<{ status?: string; statusReasons?: string[] }>, + successful: readonly string[], + failed: readonly string[], + missingIsSuccess = false, + ): Promise { + const attempts = this.roleOptions.waitAttempts ?? DEFAULT_WAIT_ATTEMPTS; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const current = await read(); + if (current.status && successful.includes(current.status)) return; + if (current.status && failed.includes(current.status)) { + throw new AgentCoreCLIError( + `${resource} reached ${current.status}: ${(current.statusReasons ?? []).join(", ")}`, + { source: ERROR_SOURCE.SERVICE }, + ); + } + } catch (error) { + if ((error as Error).name !== "ResourceNotFoundException") throw error; + if (missingIsSuccess) return; + } + if (attempt < attempts - 1) await this.wait(); + } + throw new AgentCoreCLIError(`Timed out waiting for ${resource}`, { + source: ERROR_SOURCE.SERVICE, + }); + } + + private async wait(): Promise { + const milliseconds = this.roleOptions.waitDelayMs ?? DEFAULT_WAIT_DELAY_MS; + if (milliseconds > 0) { + await ( + this.roleOptions.sleep ?? ((delay) => new Promise((resolve) => setTimeout(resolve, delay))) + )(milliseconds); + } + } + private static required(value: T | undefined, resource: string, field: string): T { if (value === undefined) { throw new AgentCoreCLIError(`${resource} is missing its ${field} required for update`, { @@ -426,3 +795,11 @@ export class GatewayClient implements CoreGatewayClient { ); } } + +function credentialProviderName(providerArn: string, kind: "api-key" | "oauth"): string { + const resource = providerArn.split(":").slice(5).join(":"); + const type = kind === "api-key" ? "apikeycredentialprovider" : "oauth2credentialprovider"; + const name = resource.match(new RegExp(`^token-vault/[^/]+/${type}/([^/]+)$`))?.[1]; + if (!name) throw new Error(`Invalid ${kind} credential provider ARN: ${providerArn}`); + return name; +} diff --git a/src/core/gatewayExecutionRole.test.ts b/src/core/gatewayExecutionRole.test.ts new file mode 100644 index 000000000..0f7842b66 --- /dev/null +++ b/src/core/gatewayExecutionRole.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { PutRolePolicyCommand, type IAMClient } from "@aws-sdk/client-iam"; +import { GatewayExecutionRole } from "./gatewayExecutionRole"; +import type { GatewayPolicyStatement } from "./gatewayPolicy"; + +const ROLE_ARN = "arn:aws:iam::123456789012:role/AgentCoreCliGateway-orders"; +const current: GatewayPolicyStatement[] = [ + { Effect: "Allow", Action: ["lambda:InvokeFunction"], Resource: ["arn:lambda:old"] }, +]; +const desired: GatewayPolicyStatement[] = [ + { Effect: "Allow", Action: ["lambda:InvokeFunction"], Resource: ["arn:lambda:new"] }, +]; + +function policyWrites(): { iam: IAMClient; writes: GatewayPolicyStatement[][] } { + const writes: GatewayPolicyStatement[][] = []; + const iam = { + send: async (command: PutRolePolicyCommand) => { + expect(command).toBeInstanceOf(PutRolePolicyCommand); + writes.push(JSON.parse(command.input.PolicyDocument!).Statement); + return {}; + }, + } as unknown as IAMClient; + return { iam, writes }; +} + +describe("GatewayExecutionRole update", () => { + test("stages current and desired grants before writing exact desired", async () => { + const { iam, writes } = policyWrites(); + const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); + + await role.update(ROLE_ARN, current, desired, async () => { + expect(writes).toEqual([[...current, ...desired]]); + return "updated"; + }); + + expect(writes).toEqual([[...current, ...desired], desired]); + }); + + test("restores current grants when the Gateway operation fails", async () => { + const { iam, writes } = policyWrites(); + const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); + + await expect( + role.update(ROLE_ARN, current, desired, async () => { + throw new Error("update failed"); + }), + ).rejects.toThrow("update failed"); + + expect(writes).toEqual([[...current, ...desired], current]); + }); +}); diff --git a/src/core/gatewayExecutionRole.ts b/src/core/gatewayExecutionRole.ts new file mode 100644 index 000000000..d580eb8b0 --- /dev/null +++ b/src/core/gatewayExecutionRole.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + GetRoleCommand, + GetRolePolicyCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import type { GatewayPolicyStatement } from "./gatewayPolicy"; + +const POLICY_NAME = "AgentCoreCliGatewayExecutionPolicy"; +const ROLE_PREFIX = "AgentCoreCliGateway-"; + +export type GatewayExecutionRoleOptions = { + propagationDelayMs?: number; + sleep?: (milliseconds: number) => Promise; +}; + +export type ManagedGatewayRole = { + arn: string; + name: string; + created: boolean; +}; + +export class GatewayExecutionRole { + private readonly propagationDelayMs: number; + private readonly sleep: (milliseconds: number) => Promise; + + constructor( + private readonly iam: IAMClient, + options: GatewayExecutionRoleOptions = {}, + ) { + this.propagationDelayMs = options.propagationDelayMs ?? 10_000; + this.sleep = + options.sleep ?? + ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + } + + async ensure(gatewayName: string): Promise { + const roleName = gatewayRoleName(gatewayName); + try { + const response = await this.iam.send(new GetRoleCommand({ RoleName: roleName })); + if (!response.Role?.Arn) throw new Error(`IAM returned no ARN for role ${roleName}`); + return { arn: response.Role.Arn, name: roleName, created: false }; + } catch (error) { + if ((error as Error).name !== "NoSuchEntityException") throw error; + } + + const response = await this.iam.send( + new CreateRoleCommand({ + RoleName: roleName, + AssumeRolePolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }), + }), + ); + if (!response.Role?.Arn) throw new Error(`IAM returned no ARN for role ${roleName}`); + return { arn: response.Role.Arn, name: roleName, created: true }; + } + + async rollbackCreate(role: ManagedGatewayRole): Promise { + if (!role.created) return; + await this.write(role.name, []); + await this.iam.send(new DeleteRoleCommand({ RoleName: role.name })); + } + + async read(roleArn: string): Promise { + try { + const response = await this.iam.send( + new GetRolePolicyCommand({ + RoleName: roleNameFromArn(roleArn), + PolicyName: POLICY_NAME, + }), + ); + if (!response.PolicyDocument) return []; + const document = parsePolicy(response.PolicyDocument); + return Array.isArray(document.Statement) ? document.Statement : []; + } catch (error) { + if ((error as Error).name === "NoSuchEntityException") return []; + throw error; + } + } + + async update( + roleArn: string, + current: GatewayPolicyStatement[], + desired: GatewayPolicyStatement[], + operation: () => Promise, + options: { forcePropagation?: boolean } = {}, + ): Promise { + const roleName = roleNameFromArn(roleArn); + const transition = uniqueStatements([...current, ...desired]); + const staged = JSON.stringify(transition) !== JSON.stringify(current); + if (staged) await this.write(roleName, transition); + if ((staged || options.forcePropagation) && this.propagationDelayMs > 0) { + await this.sleep(this.propagationDelayMs); + } + + let value: T; + try { + value = await operation(); + } catch (error) { + if (staged) await this.write(roleName, current); + throw error; + } + if (JSON.stringify(transition) !== JSON.stringify(desired)) { + await this.write(roleName, desired); + } + return value; + } + + async replace(roleArn: string, desired: GatewayPolicyStatement[]): Promise { + await this.write(roleNameFromArn(roleArn), desired); + } + + private async write(roleName: string, statements: GatewayPolicyStatement[]): Promise { + if (statements.length === 0) { + try { + await this.iam.send( + new DeleteRolePolicyCommand({ RoleName: roleName, PolicyName: POLICY_NAME }), + ); + } catch (error) { + if ((error as Error).name !== "NoSuchEntityException") throw error; + } + return; + } + + await this.iam.send( + new PutRolePolicyCommand({ + RoleName: roleName, + PolicyName: POLICY_NAME, + PolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: statements }), + }), + ); + } +} + +function parsePolicy(document: string): { Statement?: GatewayPolicyStatement[] } { + try { + return JSON.parse(document); + } catch { + return JSON.parse(decodeURIComponent(document)); + } +} + +export function gatewayRoleName(gatewayName: string): string { + const fullName = `${ROLE_PREFIX}${gatewayName}`; + if (fullName.length <= 64) return fullName; + const hash = createHash("sha256").update(gatewayName).digest("hex").slice(0, 8); + return `${fullName.slice(0, 55)}-${hash}`; +} + +export function isGatewayExecutionRole(gatewayName: string, roleArn: string): boolean { + return roleNameFromArn(roleArn) === gatewayRoleName(gatewayName); +} + +function uniqueStatements(statements: GatewayPolicyStatement[]): GatewayPolicyStatement[] { + return [ + ...new Map(statements.map((statement) => [JSON.stringify(statement), statement])).values(), + ]; +} + +function roleNameFromArn(roleArn: string): string { + const roleName = roleArn.split("/").at(-1); + if (!roleName) throw new Error(`Invalid IAM role ARN: ${roleArn}`); + return roleName; +} diff --git a/src/core/gatewayPolicy.test.ts b/src/core/gatewayPolicy.test.ts new file mode 100644 index 000000000..f9b19a2e4 --- /dev/null +++ b/src/core/gatewayPolicy.test.ts @@ -0,0 +1,312 @@ +import { expect, test } from "bun:test"; +import { gatewayPolicy } from "./gatewayPolicy"; + +const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders"; +const ENGINE_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders"; +const LAMBDA_ARN = "arn:aws:lambda:us-west-2:123456789012:function:orders"; +const INTERCEPTOR_ARN = "arn:aws:lambda:us-west-2:123456789012:function:interceptor"; +const TRANSFORM_ARN = "arn:aws:lambda:us-west-2:123456789012:function:transform"; +const WORKLOAD_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default/workload-identity/orders"; +const API_KEY_PROVIDER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; +const OAUTH_PROVIDER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/orders"; +const API_KEY_SECRET_ARN = "arn:aws:secretsmanager:us-west-2:123456789012:secret:api-key"; +const OAUTH_SECRET_ARN = "arn:aws:secretsmanager:us-west-2:123456789012:secret:oauth"; + +test("builds exact grants for every inferable Gateway permission family", () => { + expect( + gatewayPolicy({ + gatewayArn: GATEWAY_ARN, + workloadIdentityArn: WORKLOAD_ARN, + policyEngineArn: ENGINE_ARN, + credentialSecrets: new Map([ + [API_KEY_PROVIDER_ARN, API_KEY_SECRET_ARN], + [OAUTH_PROVIDER_ARN, OAUTH_SECRET_ARN], + ]), + interceptorConfigurations: [ + { + interceptor: { lambda: { arn: INTERCEPTOR_ARN } }, + interceptionPoints: ["REQUEST"], + }, + ], + customTransformConfiguration: { lambda: { arn: TRANSFORM_ARN } }, + targets: [ + { + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: LAMBDA_ARN, + toolSchema: { inlinePayload: [] }, + }, + }, + }, + }, + { + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "web-search" }, + }, + }, + }, + }, + { + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "bedrock-knowledge-bases" }, + configurations: [ + { + name: "Retrieve", + parameterValues: { knowledgeBaseId: "KB12345678" }, + }, + { + name: "AgenticRetrieveStream", + parameterValues: { + retrievers: [ + { + configuration: { + knowledgeBase: { knowledgeBaseId: "KB87654321" }, + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + { + targetConfiguration: { + inference: { + connector: { source: { connectorId: "bedrock-mantle" } }, + }, + }, + }, + { + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: "api123", + stage: "prod", + apiGatewayToolConfiguration: { toolFilters: [] }, + }, + }, + }, + }, + { + targetConfiguration: { + http: { + agentcoreRuntime: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders", + schema: { source: { s3: { uri: "s3://schemas/runtime.json" } } }, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + { + targetConfiguration: { + mcp: { + mcpServer: { + endpoint: "https://example.test/mcp", + mcpToolSchema: { s3: { uri: "s3://schemas/mcp.json" } }, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "JWT_PASSTHROUGH" }], + }, + { + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.test/api-key" } }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { + providerArn: API_KEY_PROVIDER_ARN, + }, + }, + }, + ], + }, + { + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.test/oauth" } }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "OAUTH", + credentialProvider: { + oauthCredentialProvider: { + providerArn: OAUTH_PROVIDER_ARN, + scopes: ["orders.read"], + }, + }, + }, + ], + }, + ], + }), + ).toEqual([ + { + Effect: "Allow", + Action: ["lambda:InvokeFunction"], + Resource: [INTERCEPTOR_ARN], + }, + { + Effect: "Allow", + Action: ["lambda:InvokeFunction"], + Resource: [TRANSFORM_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:InvokeGateway"], + Resource: [GATEWAY_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetPolicyEngine"], + Resource: [ENGINE_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:AuthorizeAction", "bedrock-agentcore:PartiallyAuthorizeActions"], + Resource: [ENGINE_ARN, GATEWAY_ARN], + }, + { + Effect: "Allow", + Action: ["lambda:InvokeFunction"], + Resource: [LAMBDA_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:InvokeWebSearch"], + Resource: [ + "arn:aws:bedrock-agentcore::aws:tool/web-search.v1", + "arn:aws:bedrock-agentcore:us-west-2:aws:tool/web-search.v1", + ], + }, + { + Effect: "Allow", + Action: ["bedrock:GetKnowledgeBase"], + Resource: [ + "arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB12345678", + "arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB87654321", + ], + }, + { + Effect: "Allow", + Action: ["bedrock:Retrieve"], + Resource: ["arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB12345678"], + }, + { + Effect: "Allow", + Action: ["bedrock:AgenticRetrieveStream"], + Resource: ["*"], + }, + { + Effect: "Allow", + Action: ["bedrock-mantle:CreateInference"], + Resource: ["arn:aws:bedrock-mantle:us-west-2:123456789012:project/*"], + }, + { + Effect: "Allow", + Action: ["bedrock-mantle:ListModels"], + Resource: ["arn:aws:bedrock-mantle:us-west-2:123456789012:project/default"], + }, + { + Effect: "Allow", + Action: ["bedrock-mantle:CallWithBearerToken"], + Resource: ["*"], + }, + { + Effect: "Allow", + Action: ["execute-api:Invoke"], + Resource: ["arn:aws:execute-api:us-west-2:123456789012:api123/prod/*/*"], + }, + { + Effect: "Allow", + Action: ["s3:GetObject"], + Resource: ["arn:aws:s3:::schemas/runtime.json"], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:InvokeAgentRuntime"], + Resource: ["arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders"], + }, + { + Effect: "Allow", + Action: ["s3:GetObject"], + Resource: ["arn:aws:s3:::schemas/mcp.json"], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetWorkloadAccessToken"], + Resource: [ + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default", + WORKLOAD_ARN, + ], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetResourceApiKey"], + Resource: [API_KEY_PROVIDER_ARN], + }, + { + Effect: "Allow", + Action: ["secretsmanager:GetSecretValue"], + Resource: [API_KEY_SECRET_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetResourceOauth2Token"], + Resource: [OAUTH_PROVIDER_ARN], + }, + { + Effect: "Allow", + Action: ["secretsmanager:GetSecretValue"], + Resource: [OAUTH_SECRET_ARN], + }, + ]); +}); + +test("rejects an IAM-signed external endpoint whose permissions cannot be inferred", () => { + expect(() => + gatewayPolicy({ + gatewayArn: GATEWAY_ARN, + targets: [ + { + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://example.test", + protocolType: "CUSTOM", + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + ], + }), + ).toThrow(/manage this role externally/); +}); + +test("stages an account-scoped Gateway wildcard before create returns its ARN", () => { + expect(gatewayPolicy({ policyEngineArn: ENGINE_ARN })).toEqual([ + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetPolicyEngine"], + Resource: [ENGINE_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:AuthorizeAction", "bedrock-agentcore:PartiallyAuthorizeActions"], + Resource: [ENGINE_ARN, "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/*"], + }, + ]); +}); diff --git a/src/core/gatewayPolicy.ts b/src/core/gatewayPolicy.ts new file mode 100644 index 000000000..ea6e701dd --- /dev/null +++ b/src/core/gatewayPolicy.ts @@ -0,0 +1,353 @@ +import type { + CredentialProviderConfiguration, + CustomTransformConfiguration, + GatewayInterceptorConfiguration, + TargetConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; + +export type GatewayPolicyStatement = { + Effect: "Allow"; + Action: string[]; + Resource: string[]; + Condition?: Record; +}; + +export type GatewayPolicyState = { + gatewayArn?: string; + workloadIdentityArn?: string; + policyEngineArn?: string; + interceptorConfigurations?: readonly GatewayInterceptorConfiguration[]; + customTransformConfiguration?: CustomTransformConfiguration; + credentialSecrets?: ReadonlyMap; + targets?: readonly GatewayPolicyTarget[]; +}; + +export type GatewayPolicyTarget = { + targetConfiguration: TargetConfiguration; + credentialProviderConfigurations?: readonly CredentialProviderConfiguration[]; +}; + +export function gatewayPolicy(state: GatewayPolicyState): GatewayPolicyStatement[] { + const statements: GatewayPolicyStatement[] = []; + + for (const interceptor of state.interceptorConfigurations ?? []) { + const arn = interceptor.interceptor?.lambda?.arn; + if (!arn) throw unsupported("Gateway interceptor is missing its Lambda ARN"); + statements.push(allow("lambda:InvokeFunction", arn)); + } + if (state.customTransformConfiguration) { + const arn = state.customTransformConfiguration.lambda?.arn; + if (!arn) throw unsupported("Gateway custom transform is missing its Lambda ARN"); + statements.push(allow("lambda:InvokeFunction", arn)); + } + + if (state.gatewayArn) { + statements.push(allow("bedrock-agentcore:InvokeGateway", state.gatewayArn)); + } + + if (state.policyEngineArn) { + statements.push(allow("bedrock-agentcore:GetPolicyEngine", state.policyEngineArn)); + statements.push({ + Effect: "Allow", + Action: ["bedrock-agentcore:AuthorizeAction", "bedrock-agentcore:PartiallyAuthorizeActions"], + Resource: [state.policyEngineArn, state.gatewayArn ?? gatewayWildcard(state.policyEngineArn)], + }); + } + + for (const target of state.targets ?? []) { + const configuration = target.targetConfiguration; + if (containsUnknown(configuration)) throw unsupported("Gateway Target type is not supported"); + const credentialTypes = (target.credentialProviderConfigurations ?? []).map( + ({ credentialProviderType }) => credentialProviderType, + ); + for (const credential of target.credentialProviderConfigurations ?? []) { + const apiKey = credential.credentialProviderType === "API_KEY"; + const oauth = credential.credentialProviderType === "OAUTH"; + if (!apiKey && !oauth) continue; + const providerArn = apiKey + ? credential.credentialProvider?.apiKeyCredentialProvider?.providerArn + : credential.credentialProvider?.oauthCredentialProvider?.providerArn; + if (!providerArn) throw unsupported("Credential provider ARN is missing"); + const secretArn = state.credentialSecrets?.get(providerArn); + if (!secretArn) throw unsupported(`Credential provider ${providerArn} was not resolved`); + const workloadArns = workloadIdentityResources(state.workloadIdentityArn); + statements.push( + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetWorkloadAccessToken"], + Resource: workloadArns, + }, + allow( + apiKey + ? "bedrock-agentcore:GetResourceApiKey" + : "bedrock-agentcore:GetResourceOauth2Token", + providerArn, + ), + allow("secretsmanager:GetSecretValue", secretArn), + ); + } + for (const schema of targetSchemas(configuration)) { + const uri = schema?.s3?.uri; + if (uri) statements.push(allow("s3:GetObject", s3Arn(uri, state.gatewayArn))); + } + + const lambdaArn = configuration.mcp?.lambda?.lambdaArn; + if (lambdaArn) { + requireCredentials(credentialTypes, "GATEWAY_IAM_ROLE"); + statements.push(allow("lambda:InvokeFunction", lambdaArn)); + continue; + } + + const connectorId = + configuration.mcp?.connector?.source?.connectorId ?? + configuration.inference?.connector?.source?.connectorId; + if (connectorId === "web-search") { + requireCredentials(credentialTypes, "GATEWAY_IAM_ROLE"); + if (!state.gatewayArn) throw new Error("Gateway ARN is required for Web Search permissions"); + const [prefix, partition, service, region] = state.gatewayArn.split(":"); + if (prefix !== "arn" || !partition || service !== "bedrock-agentcore" || !region) { + throw new Error(`Invalid Gateway ARN: ${state.gatewayArn}`); + } + statements.push({ + Effect: "Allow", + Action: ["bedrock-agentcore:InvokeWebSearch"], + Resource: [ + `arn:${partition}:${service}::aws:tool/web-search.v1`, + `arn:${partition}:${service}:${region}:aws:tool/web-search.v1`, + ], + }); + continue; + } + if (connectorId === "bedrock-knowledge-bases") { + requireCredentials(credentialTypes, "GATEWAY_IAM_ROLE"); + statements.push(...knowledgeBasePolicy(configuration, state.gatewayArn)); + continue; + } + if (connectorId === "bedrock-mantle") { + requireCredentials(credentialTypes, "GATEWAY_IAM_ROLE"); + const { partition, region, accountId } = gatewayContext(state.gatewayArn); + statements.push( + allow( + "bedrock-mantle:CreateInference", + `arn:${partition}:bedrock-mantle:${region}:${accountId}:project/*`, + ), + allow( + "bedrock-mantle:ListModels", + `arn:${partition}:bedrock-mantle:${region}:${accountId}:project/default`, + ), + allow("bedrock-mantle:CallWithBearerToken", "*"), + ); + continue; + } + + const apiGateway = configuration.mcp?.apiGateway; + if (apiGateway) { + requireCredentials(credentialTypes, "GATEWAY_IAM_ROLE", "API_KEY"); + if (!credentialTypes.includes("API_KEY")) { + const { partition, region, accountId } = gatewayContext(state.gatewayArn); + statements.push( + allow( + "execute-api:Invoke", + `arn:${partition}:execute-api:${region}:${accountId}:${apiGateway.restApiId}/${apiGateway.stage}/*/*`, + ), + ); + } + continue; + } + + const runtime = configuration.http?.agentcoreRuntime; + if (runtime) { + requireCredentials( + credentialTypes, + "GATEWAY_IAM_ROLE", + "CALLER_IAM_CREDENTIALS", + "JWT_PASSTHROUGH", + "OAUTH", + ); + if ( + !credentialTypes.some( + (type) => + type === "CALLER_IAM_CREDENTIALS" || type === "JWT_PASSTHROUGH" || type === "OAUTH", + ) + ) { + if (!runtime.arn) throw unsupported("AgentCore Runtime Target is missing its ARN"); + statements.push(allow("bedrock-agentcore:InvokeAgentRuntime", runtime.arn)); + } + continue; + } + + if ( + configuration.mcp?.mcpServer || + configuration.mcp?.openApiSchema || + configuration.mcp?.smithyModel || + configuration.http?.passthrough || + configuration.inference?.connector || + configuration.inference?.provider + ) { + requireCredentials( + credentialTypes, + "CALLER_IAM_CREDENTIALS", + "JWT_PASSTHROUGH", + "API_KEY", + "OAUTH", + ); + continue; + } + + throw unsupported("Gateway Target permissions cannot be inferred"); + } + + return [ + ...new Map( + statements.map((statement) => [JSON.stringify(statement), statement] as const), + ).values(), + ]; +} + +function workloadIdentityResources(workloadIdentityArn: string | undefined): string[] { + const separator = "/workload-identity/"; + const separatorIndex = workloadIdentityArn?.indexOf(separator) ?? -1; + if (!workloadIdentityArn || separatorIndex < 0) { + throw unsupported("Gateway workload identity ARN is missing"); + } + return [workloadIdentityArn.slice(0, separatorIndex), workloadIdentityArn]; +} + +function allow(action: string, resource: string): GatewayPolicyStatement { + return { Effect: "Allow", Action: [action], Resource: [resource] }; +} + +function requireCredentials( + actual: readonly (string | undefined)[], + ...allowed: readonly string[] +): void { + if (actual.some((type) => !type || !allowed.includes(type))) { + const unsupportedType = actual.find((type) => !type || !allowed.includes(type)); + throw unsupported(`Credential provider ${unsupportedType ?? "unknown"} is not supported`); + } +} + +function targetSchemas( + configuration: TargetConfiguration, +): Array<{ s3?: { uri?: string } } | undefined> { + return [ + configuration.mcp?.lambda?.toolSchema, + configuration.mcp?.mcpServer?.mcpToolSchema, + configuration.mcp?.openApiSchema, + configuration.mcp?.smithyModel, + configuration.http?.agentcoreRuntime?.schema?.source, + configuration.http?.passthrough?.schema?.source, + ]; +} + +function s3Arn(uri: string, gatewayArn: string | undefined): string { + const match = uri.match(/^s3:\/\/([^/]+)\/(.+)$/); + if (!match?.[1] || !match[2]) throw unsupported(`Invalid S3 schema URI "${uri}"`); + const { partition } = gatewayContext(gatewayArn); + return `arn:${partition}:s3:::${match[1]}/${match[2]}`; +} + +function knowledgeBasePolicy( + configuration: TargetConfiguration, + gatewayArn: string | undefined, +): GatewayPolicyStatement[] { + const connector = configuration.mcp?.connector; + const { partition, region, accountId } = gatewayContext(gatewayArn); + const all = new Set(); + const retrieve = new Set(); + let agentic = false; + + for (const tool of connector?.configurations ?? []) { + if (tool.name === "Retrieve") { + const id = nestedString(tool.parameterValues, "knowledgeBaseId"); + if (!id) throw unsupported("Knowledge Base Retrieve tool is missing knowledgeBaseId"); + all.add(id); + retrieve.add(id); + continue; + } + if (tool.name === "AgenticRetrieveStream") { + const retrievers = nestedArray(tool.parameterValues, "retrievers"); + if (!retrievers?.length) { + throw unsupported("Knowledge Base AgenticRetrieveStream tool has no retrievers"); + } + for (const retriever of retrievers) { + const id = nestedString(retriever, "configuration", "knowledgeBase", "knowledgeBaseId"); + if (!id) throw unsupported("Knowledge Base retriever is missing knowledgeBaseId"); + all.add(id); + } + agentic = true; + continue; + } + throw unsupported(`Knowledge Base tool ${tool.name ?? "unknown"} is not supported`); + } + if (all.size === 0) throw unsupported("Knowledge Base connector has no configured tools"); + + const arn = (id: string) => + `arn:${partition}:bedrock:${region}:${accountId}:knowledge-base/${id}`; + return [ + { + Effect: "Allow", + Action: ["bedrock:GetKnowledgeBase"], + Resource: [...all].sort().map(arn), + }, + ...(retrieve.size > 0 + ? [ + { + Effect: "Allow" as const, + Action: ["bedrock:Retrieve"], + Resource: [...retrieve].sort().map(arn), + }, + ] + : []), + ...(agentic ? [allow("bedrock:AgenticRetrieveStream", "*")] : []), + ]; +} + +function nestedString(value: unknown, ...path: readonly string[]): string | undefined { + let current = value; + for (const key of path) { + if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Record)[key]; + } + return typeof current === "string" && current ? current : undefined; +} + +function nestedArray(value: unknown, ...path: readonly string[]): unknown[] | undefined { + let current = value; + for (const key of path) { + if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Record)[key]; + } + return Array.isArray(current) ? current : undefined; +} + +function containsUnknown(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsUnknown); + if (!value || typeof value !== "object") return false; + const record = value as Record; + return "$unknown" in record || Object.values(record).some(containsUnknown); +} + +function gatewayContext(gatewayArn: string | undefined): { + partition: string; + region: string; + accountId: string; +} { + const [prefix, partition, service, region, accountId] = gatewayArn?.split(":") ?? []; + if (prefix !== "arn" || !partition || service !== "bedrock-agentcore" || !region || !accountId) { + throw unsupported(`Invalid Gateway ARN "${gatewayArn ?? ""}"`); + } + return { partition, region, accountId }; +} + +function unsupported(message: string): Error { + return new Error(`${message}; manage this role externally for this operation`); +} + +function gatewayWildcard(policyEngineArn: string): string { + const [prefix, partition, service, region, accountId] = policyEngineArn.split(":"); + if (prefix !== "arn" || !partition || service !== "bedrock-agentcore" || !region || !accountId) { + throw new Error(`Invalid Policy Engine ARN: ${policyEngineArn}`); + } + return `arn:${partition}:${service}:${region}:${accountId}:gateway/*`; +} diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.19a4b639326fd0de.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.19a4b639326fd0de.json new file mode 100644 index 000000000..993eebcf7 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.19a4b639326fd0de.json @@ -0,0 +1,4 @@ +{ + "gatewayId": "agentcore-cli-gateway-delete-fixture-ezjljj9oro", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.e015bbd46d1859f1.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.e015bbd46d1859f1.json deleted file mode 100644 index a7fa04545..000000000 --- a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.e015bbd46d1859f1.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "gatewayId": "agentcore-cli-gateway-delete-fixture-oiemu02wfc", - "status": "DELETING" -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.342327fcfcb605c7.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.342327fcfcb605c7.json new file mode 100644 index 000000000..8a623ead3 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.342327fcfcb605c7.json @@ -0,0 +1,4 @@ +{ + "ruleId": "de4dd841-9a60-40bd-b98e-a2051cfddd5b", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.cee854fd6fa9fd16.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.cee854fd6fa9fd16.json deleted file mode 100644 index 1b1f7d63b..000000000 --- a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.cee854fd6fa9fd16.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "ruleId": "80e073e4-eb05-4371-8e6a-f4bde522699c", - "status": "DELETING" -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.5ecec887ab4a48e6.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.5ecec887ab4a48e6.json new file mode 100644 index 000000000..aaaf68c16 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.5ecec887ab4a48e6.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", + "targetId": "ARDOBZFUWC", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.94eb9a3262a6cda2.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.94eb9a3262a6cda2.json deleted file mode 100644 index 1cbd45898..000000000 --- a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.94eb9a3262a6cda2.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", - "targetId": "U9OM2R9I8Q", - "status": "DELETING" -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.afcce9d3abeac495.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.afcce9d3abeac495.json deleted file mode 100644 index deb34f170..000000000 --- a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.afcce9d3abeac495.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", - "targetId": "JYNNGDZ42F", - "status": "DELETING" -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.bbf67dd173942336.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.bbf67dd173942336.json new file mode 100644 index 000000000..c964e2fa8 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.bbf67dd173942336.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", + "targetId": "JSO2GGRAMS", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.19a4b639326fd0de.json b/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.19a4b639326fd0de.json new file mode 100644 index 000000000..3aaae8486 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.19a4b639326fd0de.json @@ -0,0 +1,19 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", + "gatewayId": "agentcore-cli-gateway-delete-fixture-ezjljj9oro", + "createdAt": { + "$date": "2026-08-13T14:14:06.744Z" + }, + "updatedAt": { + "$date": "2026-08-13T14:14:07.551Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-delete-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-delete-fixture-ezjljj9oro.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Delete fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGatewayDeleteFixtureRole", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-delete-fixture-ezjljj9oro" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.94eb9a3262a6cda2.json b/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.5ecec887ab4a48e6.json similarity index 62% rename from src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.94eb9a3262a6cda2.json rename to src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.5ecec887ab4a48e6.json index b824529b7..5c8f2f884 100644 --- a/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.94eb9a3262a6cda2.json +++ b/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.5ecec887ab4a48e6.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", - "targetId": "U9OM2R9I8Q", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", + "targetId": "ARDOBZFUWC", "createdAt": { - "$date": "2026-08-07T17:54:45.522Z" + "$date": "2026-08-13T14:14:09.417Z" }, "updatedAt": { - "$date": "2026-08-07T17:54:46.453Z" + "$date": "2026-08-13T14:14:10.049Z" }, "status": "READY", "name": "web-search-delete-fixture", @@ -13,7 +13,8 @@ "mcp": { "connector": { "source": { - "connectorId": "web-search" + "connectorId": "web-search", + "version": "1.1.0" }, "configurations": [ { diff --git a/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json index 1cbd45898..aaaf68c16 100644 --- a/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json +++ b/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json @@ -1,5 +1,5 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", - "targetId": "U9OM2R9I8Q", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", + "targetId": "ARDOBZFUWC", "status": "DELETING" } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json index a7fa04545..993eebcf7 100644 --- a/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json +++ b/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json @@ -1,4 +1,4 @@ { - "gatewayId": "agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "gatewayId": "agentcore-cli-gateway-delete-fixture-ezjljj9oro", "status": "DELETING" } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/resources.json b/src/handlers/gateway/__fixtures__/delete/resources.json index fa612366a..c86a165ab 100644 --- a/src/handlers/gateway/__fixtures__/delete/resources.json +++ b/src/handlers/gateway/__fixtures__/delete/resources.json @@ -1,7 +1,7 @@ { - "gatewayId": "agentcore-cli-gateway-delete-fixture-oiemu02wfc", - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", - "targetId": "JYNNGDZ42F", - "connectorId": "U9OM2R9I8Q", - "ruleId": "80e073e4-eb05-4371-8e6a-f4bde522699c" + "gatewayId": "agentcore-cli-gateway-delete-fixture-ezjljj9oro", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", + "targetId": "JSO2GGRAMS", + "connectorId": "ARDOBZFUWC", + "ruleId": "de4dd841-9a60-40bd-b98e-a2051cfddd5b" } diff --git a/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json index 1b1f7d63b..8a623ead3 100644 --- a/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json +++ b/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json @@ -1,4 +1,4 @@ { - "ruleId": "80e073e4-eb05-4371-8e6a-f4bde522699c", + "ruleId": "de4dd841-9a60-40bd-b98e-a2051cfddd5b", "status": "DELETING" } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json index deb34f170..c964e2fa8 100644 --- a/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json +++ b/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json @@ -1,5 +1,5 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", - "targetId": "JYNNGDZ42F", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", + "targetId": "JSO2GGRAMS", "status": "DELETING" } \ No newline at end of file diff --git a/src/handlers/gateway/connector/create/index.tsx b/src/handlers/gateway/connector/create/index.tsx index 329479a1b..8467c1d67 100644 --- a/src/handlers/gateway/connector/create/index.tsx +++ b/src/handlers/gateway/connector/create/index.tsx @@ -12,6 +12,7 @@ import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; import type { CreateGatewayTargetInput } from "../../types"; +import { warnForGatewayRolePolicyUpdate } from "../../rolePolicyWarning"; import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; export const createCreateGatewayConnectorHandler = (core: Core, io: AppIO) => @@ -135,8 +136,10 @@ export const createCreateGatewayConnectorHandler = (core: Core, io: AppIO) => ...(privateEndpoint ? { privateEndpoint } : {}), }; + const options = coreOptsFromCtx(ctx); + await warnForGatewayRolePolicyUpdate(core, io, flags["gateway-id"], options); ctx .require(JsonRendererKey) - .renderJson(await core.gateway.createGatewayTarget(input, coreOptsFromCtx(ctx))); + .renderJson(await core.gateway.createGatewayTarget(input, options)); }, }); diff --git a/src/handlers/gateway/connector/update/index.tsx b/src/handlers/gateway/connector/update/index.tsx index 4ec6d3927..c22b4929e 100644 --- a/src/handlers/gateway/connector/update/index.tsx +++ b/src/handlers/gateway/connector/update/index.tsx @@ -18,6 +18,7 @@ import { } from "../../../utils"; import type { GatewayTargetUpdatePatch } from "../../types"; import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; +import { warnForGatewayRolePolicyUpdate } from "../../rolePolicyWarning"; export const createUpdateGatewayConnectorHandler = (core: Core, io: AppIO) => createHandler({ @@ -62,6 +63,7 @@ export const createUpdateGatewayConnectorHandler = (core: Core, io: AppIO) => flag("clear-credential-provider-configurations", "remove outbound credentials", z.boolean()), flag("clear-metadata-configuration", "remove metadata propagation", z.boolean()), flag("clear-private-endpoint", "remove private endpoint configuration", z.boolean()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -162,10 +164,19 @@ export const createUpdateGatewayConnectorHandler = (core: Core, io: AppIO) => gatewayId: flags["gateway-id"], targetId: flags.id, ...mutations, + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), }; + const options = coreOptsFromCtx(ctx); + await warnForGatewayRolePolicyUpdate( + core, + io, + flags["gateway-id"], + options, + flags["skip-role-policy-update"], + ); ctx .require(JsonRendererKey) - .renderJson(await core.gateway.updateGatewayConnector(patch, coreOptsFromCtx(ctx))); + .renderJson(await core.gateway.updateGatewayConnector(patch, options)); }, }); diff --git a/src/handlers/gateway/create/index.tsx b/src/handlers/gateway/create/index.tsx index be0f56fa1..13866d161 100644 --- a/src/handlers/gateway/create/index.tsx +++ b/src/handlers/gateway/create/index.tsx @@ -5,7 +5,7 @@ import type { } from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { InputValidationError } from "../../../errors"; -import { type AppIO, SourceResolver } from "../../../io"; +import { type AppIO, SourceResolver, warn } from "../../../io"; import { createHandler, flag } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { Core } from "../../types"; @@ -64,9 +64,6 @@ export const createCreateGatewayHandler = (core: Core, io: AppIO) => if (!flags.name) { throw new InputValidationError("required option '--name ' not specified"); } - if (!flags["role-arn"]) { - throw new InputValidationError("required option '--role-arn ' not specified"); - } if (!flags["authorizer-type"]) { throw new InputValidationError( "required option '--authorizer-type ' not specified", @@ -120,7 +117,7 @@ export const createCreateGatewayHandler = (core: Core, io: AppIO) => const input: CreateGatewayInput = { name: flags.name, - roleArn: flags["role-arn"], + ...(flags["role-arn"] ? { roleArn: flags["role-arn"] } : {}), ...(flags.protocol ? { protocol: flags.protocol } : {}), authorizerType: flags["authorizer-type"], ...(flags.description ? { description: flags.description } : {}), @@ -134,6 +131,12 @@ export const createCreateGatewayHandler = (core: Core, io: AppIO) => ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), }; + if (flags["role-arn"]) { + warn( + io, + `Using customer-managed execution role ${flags["role-arn"]}; IAM policies will not be modified.`, + ); + } ctx .require(JsonRendererKey) .renderJson(await core.gateway.createGateway(input, coreOptsFromCtx(ctx))); diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index 9d920b925..894e94021 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -221,11 +221,6 @@ async function cleanup(state: FixtureState): Promise { describe("Gateway create validation", () => { test.each([ ["Gateway name", ["gateway", "create", "--authorizer-type", "NONE"], /--name/], - [ - "Gateway role", - ["gateway", "create", "--name", "orders", "--authorizer-type", "NONE"], - /--role-arn/, - ], [ "Gateway authorizer", ["gateway", "create", "--name", "orders", "--role-arn", TEST_ROLE_ARN], diff --git a/src/handlers/gateway/gateway.update.test.tsx b/src/handlers/gateway/gateway.update.test.tsx index d27609816..a6bd6bb5d 100644 --- a/src/handlers/gateway/gateway.update.test.tsx +++ b/src/handlers/gateway/gateway.update.test.tsx @@ -144,6 +144,7 @@ describe("Gateway update patch mapping", () => { "--policy-engine-mode", "enforce", "--clear-exception-level", + "--skip-role-policy-update", ]); expect(core.gateway.calls.find((call) => call.method === "updateGateway")?.args[0]).toEqual({ @@ -152,6 +153,7 @@ describe("Gateway update patch mapping", () => { clearProtocol: true, policyEngineConfiguration: { mode: "ENFORCE" }, exceptionLevel: null, + skipRolePolicyUpdate: true, }); }); @@ -168,6 +170,7 @@ describe("Gateway update patch mapping", () => { '{"http":{"passthrough":{"endpoint":"https://example.test","protocolType":"CUSTOM"}}}', "--clear-description", "--clear-credential-provider-configurations", + "--skip-role-policy-update", ]); expect( @@ -180,6 +183,7 @@ describe("Gateway update patch mapping", () => { http: { passthrough: { endpoint: "https://example.test", protocolType: "CUSTOM" } }, }, credentialProviderConfigurations: null, + skipRolePolicyUpdate: true, }); }); @@ -194,6 +198,7 @@ describe("Gateway update patch mapping", () => { "target-1", "--connector", "web-search", + "--skip-role-policy-update", ]); expect( @@ -214,6 +219,7 @@ describe("Gateway update patch mapping", () => { }, }, }, + skipRolePolicyUpdate: true, }); }); diff --git a/src/handlers/gateway/rolePolicyWarning.ts b/src/handlers/gateway/rolePolicyWarning.ts new file mode 100644 index 000000000..2ffd80681 --- /dev/null +++ b/src/handlers/gateway/rolePolicyWarning.ts @@ -0,0 +1,19 @@ +import { warn, type AppIO } from "../../io"; +import type { CoreOptions } from "../../core/types"; +import type { Core } from "../types"; + +export async function warnForGatewayRolePolicyUpdate( + core: Core, + io: AppIO, + gatewayId: string, + options: CoreOptions, + skip = false, +): Promise { + if (skip) return; + const roleArn = await core.gateway.getGatewayRolePolicyWarning(gatewayId, options); + if (!roleArn) return; + warn( + io, + `Execution role ${roleArn} is not managed by the AgentCore CLI. IAM policies will not be modified; you are responsible for permissions required by this operation.`, + ); +} diff --git a/src/handlers/gateway/target/create/index.tsx b/src/handlers/gateway/target/create/index.tsx index 7bfa7fd3c..bd358759a 100644 --- a/src/handlers/gateway/target/create/index.tsx +++ b/src/handlers/gateway/target/create/index.tsx @@ -13,6 +13,7 @@ import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; import type { CreateGatewayTargetInput } from "../../types"; +import { warnForGatewayRolePolicyUpdate } from "../../rolePolicyWarning"; export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => createHandler({ @@ -118,8 +119,10 @@ export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), }; + const options = coreOptsFromCtx(ctx); + await warnForGatewayRolePolicyUpdate(core, io, flags["gateway-id"], options); ctx .require(JsonRendererKey) - .renderJson(await core.gateway.createGatewayTarget(input, coreOptsFromCtx(ctx))); + .renderJson(await core.gateway.createGatewayTarget(input, options)); }, }); diff --git a/src/handlers/gateway/target/update/index.tsx b/src/handlers/gateway/target/update/index.tsx index b2f9983b7..989e903c6 100644 --- a/src/handlers/gateway/target/update/index.tsx +++ b/src/handlers/gateway/target/update/index.tsx @@ -17,6 +17,7 @@ import { parseJsonObjectFlag, } from "../../../utils"; import type { GatewayTargetUpdatePatch } from "../../types"; +import { warnForGatewayRolePolicyUpdate } from "../../rolePolicyWarning"; export const createUpdateGatewayTargetHandler = (core: Core, io: AppIO) => createHandler({ @@ -52,6 +53,7 @@ export const createUpdateGatewayTargetHandler = (core: Core, io: AppIO) => flag("clear-credential-provider-configurations", "remove outbound credentials", z.boolean()), flag("clear-metadata-configuration", "remove metadata propagation", z.boolean()), flag("clear-private-endpoint", "remove private endpoint configuration", z.boolean()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -128,10 +130,19 @@ export const createUpdateGatewayTargetHandler = (core: Core, io: AppIO) => gatewayId: flags["gateway-id"], targetId: flags["target-id"], ...mutations, + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), }; + const options = coreOptsFromCtx(ctx); + await warnForGatewayRolePolicyUpdate( + core, + io, + flags["gateway-id"], + options, + flags["skip-role-policy-update"], + ); ctx .require(JsonRendererKey) - .renderJson(await core.gateway.updateGatewayTarget(patch, coreOptsFromCtx(ctx))); + .renderJson(await core.gateway.updateGatewayTarget(patch, options)); }, }); diff --git a/src/handlers/gateway/types.tsx b/src/handlers/gateway/types.tsx index 7342d3fa6..b51ca14a1 100644 --- a/src/handlers/gateway/types.tsx +++ b/src/handlers/gateway/types.tsx @@ -25,8 +25,9 @@ import type { CoreOptions } from "../../core/types"; export type GatewayProtocol = "mcp"; -export type CreateGatewayInput = Omit & { +export type CreateGatewayInput = Omit & { protocol?: GatewayProtocol; + roleArn?: string; }; export type CreateGatewayTargetInput = CreateGatewayTargetRequest; @@ -36,6 +37,7 @@ export type CreateGatewayRuleInput = CreateGatewayRuleRequest; export type GatewayUpdatePatch = { id: string; roleArn?: UpdateGatewayRequest["roleArn"]; + skipRolePolicyUpdate?: boolean; clearProtocol?: boolean; description?: UpdateGatewayRequest["description"] | null; protocolConfiguration?: UpdateGatewayRequest["protocolConfiguration"] | null; @@ -52,6 +54,7 @@ export type GatewayUpdatePatch = { export type GatewayTargetUpdatePatch = { gatewayId: string; targetId: string; + skipRolePolicyUpdate?: boolean; name?: UpdateGatewayTargetRequest["name"]; description?: UpdateGatewayTargetRequest["description"] | null; endpoint?: string; @@ -65,6 +68,7 @@ export type GatewayTargetUpdatePatch = { export type GatewayRuleUpdateInput = UpdateGatewayRuleRequest; export interface CoreGatewayClient { + getGatewayRolePolicyWarning(gatewayId: string, options: CoreOptions): Promise; createGateway(input: CreateGatewayInput, options: CoreOptions): Promise; updateGateway(patch: GatewayUpdatePatch, options: CoreOptions): Promise; getGateway(id: string, options: CoreOptions): Promise; diff --git a/src/handlers/gateway/update/index.tsx b/src/handlers/gateway/update/index.tsx index f0fd94918..d8e580604 100644 --- a/src/handlers/gateway/update/index.tsx +++ b/src/handlers/gateway/update/index.tsx @@ -18,6 +18,7 @@ import { parseJsonObjectFlag, } from "../../utils"; import type { GatewayUpdatePatch } from "../types"; +import { warnForGatewayRolePolicyUpdate } from "../rolePolicyWarning"; export const createUpdateGatewayHandler = (core: Core, io: AppIO) => createHandler({ @@ -71,6 +72,7 @@ export const createUpdateGatewayHandler = (core: Core, io: AppIO) => flag("clear-policy-engine", "detach the Policy Engine", z.boolean()), flag("clear-exception-level", "return to generic invocation errors", z.boolean()), flag("clear-waf-configuration", "reset WAF failure mode to FAIL_CLOSE", z.boolean()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags.id) { @@ -187,10 +189,22 @@ export const createUpdateGatewayHandler = (core: Core, io: AppIO) => if (Object.values(mutations).every((value) => value === undefined)) { throw new InputValidationError("Gateway update requires at least one mutation option"); } - const patch: GatewayUpdatePatch = { id: flags.id, ...mutations }; + const patch: GatewayUpdatePatch = { + id: flags.id, + ...mutations, + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), + }; - ctx - .require(JsonRendererKey) - .renderJson(await core.gateway.updateGateway(patch, coreOptsFromCtx(ctx))); + const options = coreOptsFromCtx(ctx); + if (!flags["role-arn"]) { + await warnForGatewayRolePolicyUpdate( + core, + io, + flags.id, + options, + flags["skip-role-policy-update"], + ); + } + ctx.require(JsonRendererKey).renderJson(await core.gateway.updateGateway(patch, options)); }, }); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 999f7f8ed..e9875d45e 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -877,6 +877,14 @@ export class TestGatewayClient implements CoreGatewayClient { private getRuleResponse: GetGatewayRuleResponse = DEFAULT_GET_GATEWAY_RULE_RESPONSE; private listRuleResponses = new Map(); private deleteRuleResponse: DeleteGatewayRuleResponse = DEFAULT_DELETE_GATEWAY_RULE_RESPONSE; + + async getGatewayRolePolicyWarning( + gatewayId: string, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "getGatewayRolePolicyWarning", args: [gatewayId, options] }); + return undefined; + } private error?: Error; setGetResponse(response: GetGatewayResponse): this { From 64282c33ef9a48d0776f9cc68c4dc507fcdf0926 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 15:04:27 +0000 Subject: [PATCH 2/9] fix(gateway): grant JWT workload token access --- src/core/gatewayPolicy.test.ts | 9 +++++++++ src/core/gatewayPolicy.ts | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/core/gatewayPolicy.test.ts b/src/core/gatewayPolicy.test.ts index f9b19a2e4..814da437e 100644 --- a/src/core/gatewayPolicy.test.ts +++ b/src/core/gatewayPolicy.test.ts @@ -145,6 +145,7 @@ test("builds exact grants for every inferable Gateway permission family", () => oauthCredentialProvider: { providerArn: OAUTH_PROVIDER_ARN, scopes: ["orders.read"], + grantType: "AUTHORIZATION_CODE", }, }, }, @@ -262,6 +263,14 @@ test("builds exact grants for every inferable Gateway permission family", () => Action: ["secretsmanager:GetSecretValue"], Resource: [API_KEY_SECRET_ARN], }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetWorkloadAccessTokenForJWT"], + Resource: [ + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default", + WORKLOAD_ARN, + ], + }, { Effect: "Allow", Action: ["bedrock-agentcore:GetResourceOauth2Token"], diff --git a/src/core/gatewayPolicy.ts b/src/core/gatewayPolicy.ts index ea6e701dd..0ad262d58 100644 --- a/src/core/gatewayPolicy.ts +++ b/src/core/gatewayPolicy.ts @@ -71,10 +71,15 @@ export function gatewayPolicy(state: GatewayPolicyState): GatewayPolicyStatement const secretArn = state.credentialSecrets?.get(providerArn); if (!secretArn) throw unsupported(`Credential provider ${providerArn} was not resolved`); const workloadArns = workloadIdentityResources(state.workloadIdentityArn); + const grantType = credential.credentialProvider?.oauthCredentialProvider?.grantType; statements.push( { Effect: "Allow", - Action: ["bedrock-agentcore:GetWorkloadAccessToken"], + Action: [ + oauth && grantType && grantType !== "CLIENT_CREDENTIALS" + ? "bedrock-agentcore:GetWorkloadAccessTokenForJWT" + : "bedrock-agentcore:GetWorkloadAccessToken", + ], Resource: workloadArns, }, allow( From 00183a4bc965ce607af9dc605b2c594839646ffb Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 15:07:21 +0000 Subject: [PATCH 3/9] fix(gateway): scope credential token access --- src/core/gatewayPolicy.test.ts | 14 ++++++++++++-- src/core/gatewayPolicy.ts | 24 ++++++++++++++++++------ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/core/gatewayPolicy.test.ts b/src/core/gatewayPolicy.test.ts index 814da437e..bc1b3238b 100644 --- a/src/core/gatewayPolicy.test.ts +++ b/src/core/gatewayPolicy.test.ts @@ -256,7 +256,12 @@ test("builds exact grants for every inferable Gateway permission family", () => { Effect: "Allow", Action: ["bedrock-agentcore:GetResourceApiKey"], - Resource: [API_KEY_PROVIDER_ARN], + Resource: [ + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default", + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default", + WORKLOAD_ARN, + API_KEY_PROVIDER_ARN, + ], }, { Effect: "Allow", @@ -274,7 +279,12 @@ test("builds exact grants for every inferable Gateway permission family", () => { Effect: "Allow", Action: ["bedrock-agentcore:GetResourceOauth2Token"], - Resource: [OAUTH_PROVIDER_ARN], + Resource: [ + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default", + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default", + WORKLOAD_ARN, + OAUTH_PROVIDER_ARN, + ], }, { Effect: "Allow", diff --git a/src/core/gatewayPolicy.ts b/src/core/gatewayPolicy.ts index 0ad262d58..76e7e1265 100644 --- a/src/core/gatewayPolicy.ts +++ b/src/core/gatewayPolicy.ts @@ -82,12 +82,15 @@ export function gatewayPolicy(state: GatewayPolicyState): GatewayPolicyStatement ], Resource: workloadArns, }, - allow( - apiKey - ? "bedrock-agentcore:GetResourceApiKey" - : "bedrock-agentcore:GetResourceOauth2Token", - providerArn, - ), + { + Effect: "Allow", + Action: [ + apiKey + ? "bedrock-agentcore:GetResourceApiKey" + : "bedrock-agentcore:GetResourceOauth2Token", + ], + Resource: credentialProviderResources(providerArn, workloadArns), + }, allow("secretsmanager:GetSecretValue", secretArn), ); } @@ -218,6 +221,15 @@ function workloadIdentityResources(workloadIdentityArn: string | undefined): str return [workloadIdentityArn.slice(0, separatorIndex), workloadIdentityArn]; } +function credentialProviderResources(providerArn: string, workloadArns: string[]): string[] { + const providerMarker = providerArn.includes("/apikeycredentialprovider/") + ? "/apikeycredentialprovider/" + : "/oauth2credentialprovider/"; + const providerIndex = providerArn.indexOf(providerMarker); + if (providerIndex < 0) throw unsupported(`Invalid credential provider ARN ${providerArn}`); + return [providerArn.slice(0, providerIndex), ...workloadArns, providerArn]; +} + function allow(action: string, resource: string): GatewayPolicyStatement { return { Effect: "Allow", Action: [action], Resource: [resource] }; } From 2f90c256f43f24b6b57081d4f2333296a9450a5a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 15:14:46 +0000 Subject: [PATCH 4/9] fix(gateway): grant token vault KMS access --- src/core/gateway.test.ts | 7 +++++ src/core/gateway.tsx | 51 +++++++++++++++++++++------------- src/core/gatewayPolicy.test.ts | 7 +++++ src/core/gatewayPolicy.ts | 4 +++ 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index a2322000d..2facf7224 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -8,6 +8,7 @@ import { GetApiKeyCredentialProviderCommand, GetGatewayCommand, GetGatewayTargetCommand, + GetTokenVaultCommand, ListGatewayTargetsCommand, TargetType, UpdateGatewayCommand, @@ -346,6 +347,12 @@ test("stages a Lambda grant without dropping existing target and auth grants", a apiKeySecretArn: { secretArn }, }; } + if (command instanceof GetTokenVaultCommand) { + return { + tokenVaultId: "default", + kmsConfiguration: { keyType: "ServiceManagedKey" }, + }; + } if (command instanceof CreateGatewayTargetCommand) { expect(JSON.stringify(policies.at(-1))).toContain( "arn:aws:lambda:us-west-2:123456789012:function:orders", diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index effb1efe7..838a5c874 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -10,6 +10,7 @@ import { GetGatewayRuleCommand, GetGatewayTargetCommand, GetOauth2CredentialProviderCommand, + GetTokenVaultCommand, ListGatewayRulesCommand, ListGatewaysCommand, ListGatewayTargetsCommand, @@ -71,6 +72,11 @@ type GatewayClientOptions = GatewayExecutionRoleOptions & { waitDelayMs?: number; }; +type GatewayCredentialState = { + secrets: ReadonlyMap; + tokenVaultKmsKeyArn?: string; +}; + export class GatewayClient implements CoreGatewayClient { constructor( private readonly clients: AwsClients, @@ -234,8 +240,8 @@ export class GatewayClient implements CoreGatewayClient { if (!isGatewayExecutionRole(name, roleArn)) return operation(); const roleManager = this.executionRole(options); const targets = await this.targetInventory(patch.id, options); - const credentialSecrets = await this.credentialSecrets(targets, options); - const currentPolicy = this.policy(current, targets, credentialSecrets); + const credentials = await this.credentials(targets, options); + const currentPolicy = this.policy(current, targets, credentials); const desiredPolicy = this.policy( { ...current, @@ -244,7 +250,7 @@ export class GatewayClient implements CoreGatewayClient { customTransformConfiguration, }, targets, - credentialSecrets, + credentials, ); return roleManager.update(roleArn, currentPolicy, desiredPolicy, async () => { const response = await operation(); @@ -326,9 +332,9 @@ export class GatewayClient implements CoreGatewayClient { credentialProviderConfigurations: input.credentialProviderConfigurations, }, ]; - const credentialSecrets = await this.credentialSecrets(desiredTargets, options); - const current = this.policy(gateway, targets, credentialSecrets); - const desired = this.policy(gateway, desiredTargets, credentialSecrets); + const credentials = await this.credentials(desiredTargets, options); + const current = this.policy(gateway, targets, credentials); + const desired = this.policy(gateway, desiredTargets, credentials); return this.executionRole(options).update(roleArn, current, desired, async () => { const response = await operation(); const targetId = GatewayClient.required(response.targetId, "Created Gateway Target", "ID"); @@ -416,10 +422,10 @@ export class GatewayClient implements CoreGatewayClient { const response = await operation(); await this.waitForGatewayTargetDeletion(gatewayId, targetId, options); const remaining = await this.targetInventory(gatewayId, options); - const credentialSecrets = await this.credentialSecrets(remaining, options); + const credentials = await this.credentials(remaining, options); await this.executionRole(options).replace( roleArn, - this.policy(gateway, remaining, credentialSecrets), + this.policy(gateway, remaining, credentials), ); return response; } @@ -555,12 +561,9 @@ export class GatewayClient implements CoreGatewayClient { const desiredTargets = targets.map((target) => target.targetId === patch.targetId ? { ...target, targetConfiguration } : target, ); - const credentialSecrets = await this.credentialSecrets( - [...targets, ...desiredTargets], - options, - ); - const currentPolicy = this.policy(gateway, targets, credentialSecrets); - const desiredPolicy = this.policy(gateway, desiredTargets, credentialSecrets); + const credentials = await this.credentials([...targets, ...desiredTargets], options); + const currentPolicy = this.policy(gateway, targets, credentials); + const desiredPolicy = this.policy(gateway, desiredTargets, credentials); return this.executionRole(options).update(roleArn, currentPolicy, desiredPolicy, async () => { const response = await operation(); await this.waitForGatewayTarget(patch.gatewayId, patch.targetId, options); @@ -598,7 +601,7 @@ export class GatewayClient implements CoreGatewayClient { GetGatewayTargetResponse, "targetConfiguration" | "credentialProviderConfigurations" >[], - credentialSecrets: ReadonlyMap = new Map(), + credentials: GatewayCredentialState = { secrets: new Map() }, ): GatewayPolicyStatement[] { return gatewayPolicy({ gatewayArn: GatewayClient.required(gateway.gatewayArn, "Gateway", "ARN"), @@ -606,7 +609,8 @@ export class GatewayClient implements CoreGatewayClient { policyEngineArn: gateway.policyEngineConfiguration?.arn, interceptorConfigurations: gateway.interceptorConfigurations, customTransformConfiguration: gateway.customTransformConfiguration, - credentialSecrets, + credentialSecrets: credentials.secrets, + tokenVaultKmsKeyArn: credentials.tokenVaultKmsKeyArn, targets: targets.map((target) => ({ targetConfiguration: GatewayClient.required( target.targetConfiguration, @@ -618,10 +622,10 @@ export class GatewayClient implements CoreGatewayClient { }); } - private async credentialSecrets( + private async credentials( targets: readonly Pick[], options: CoreOptions, - ): Promise> { + ): Promise { const providers = new Map(); for (const target of targets) { for (const configuration of target.credentialProviderConfigurations ?? []) { @@ -662,7 +666,16 @@ export class GatewayClient implements CoreGatewayClient { if (!secretArn) throw new Error(`Credential provider ${providerArn} returned no secret ARN`); secrets.set(providerArn, secretArn); } - return secrets; + if (providers.size === 0) return { secrets }; + const vault = await control.send(new GetTokenVaultCommand({ tokenVaultId: "default" })); + const tokenVaultKmsKeyArn = + vault.kmsConfiguration?.keyType === "CustomerManagedKey" + ? vault.kmsConfiguration.kmsKeyArn + : undefined; + if (vault.kmsConfiguration?.keyType === "CustomerManagedKey" && !tokenVaultKmsKeyArn) { + throw new Error("Default Token Vault returned no customer-managed KMS key ARN"); + } + return { secrets, tokenVaultKmsKeyArn }; } private async targetInventory( diff --git a/src/core/gatewayPolicy.test.ts b/src/core/gatewayPolicy.test.ts index bc1b3238b..6169a27be 100644 --- a/src/core/gatewayPolicy.test.ts +++ b/src/core/gatewayPolicy.test.ts @@ -14,6 +14,7 @@ const OAUTH_PROVIDER_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/orders"; const API_KEY_SECRET_ARN = "arn:aws:secretsmanager:us-west-2:123456789012:secret:api-key"; const OAUTH_SECRET_ARN = "arn:aws:secretsmanager:us-west-2:123456789012:secret:oauth"; +const TOKEN_VAULT_KEY_ARN = "arn:aws:kms:us-west-2:123456789012:key/token-vault"; test("builds exact grants for every inferable Gateway permission family", () => { expect( @@ -25,6 +26,7 @@ test("builds exact grants for every inferable Gateway permission family", () => [API_KEY_PROVIDER_ARN, API_KEY_SECRET_ARN], [OAUTH_PROVIDER_ARN, OAUTH_SECRET_ARN], ]), + tokenVaultKmsKeyArn: TOKEN_VAULT_KEY_ARN, interceptorConfigurations: [ { interceptor: { lambda: { arn: INTERCEPTOR_ARN } }, @@ -179,6 +181,11 @@ test("builds exact grants for every inferable Gateway permission family", () => Action: ["bedrock-agentcore:AuthorizeAction", "bedrock-agentcore:PartiallyAuthorizeActions"], Resource: [ENGINE_ARN, GATEWAY_ARN], }, + { + Effect: "Allow", + Action: ["kms:Decrypt"], + Resource: [TOKEN_VAULT_KEY_ARN], + }, { Effect: "Allow", Action: ["lambda:InvokeFunction"], diff --git a/src/core/gatewayPolicy.ts b/src/core/gatewayPolicy.ts index 76e7e1265..c6c5cf16f 100644 --- a/src/core/gatewayPolicy.ts +++ b/src/core/gatewayPolicy.ts @@ -19,6 +19,7 @@ export type GatewayPolicyState = { interceptorConfigurations?: readonly GatewayInterceptorConfiguration[]; customTransformConfiguration?: CustomTransformConfiguration; credentialSecrets?: ReadonlyMap; + tokenVaultKmsKeyArn?: string; targets?: readonly GatewayPolicyTarget[]; }; @@ -53,6 +54,9 @@ export function gatewayPolicy(state: GatewayPolicyState): GatewayPolicyStatement Resource: [state.policyEngineArn, state.gatewayArn ?? gatewayWildcard(state.policyEngineArn)], }); } + if (state.tokenVaultKmsKeyArn) { + statements.push(allow("kms:Decrypt", state.tokenVaultKmsKeyArn)); + } for (const target of state.targets ?? []) { const configuration = target.targetConfiguration; From 116d459da8981373db0665101f11719a56c9906a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 15:50:35 +0000 Subject: [PATCH 5/9] test(gateway): minimize delete fixture update --- ...DeleteGatewayCommand.19a4b639326fd0de.json | 4 ---- ...DeleteGatewayCommand.e015bbd46d1859f1.json | 4 ++++ ...teGatewayRuleCommand.342327fcfcb605c7.json | 4 ---- ...teGatewayRuleCommand.cee854fd6fa9fd16.json | 4 ++++ ...GatewayTargetCommand.5ecec887ab4a48e6.json | 5 ----- ...GatewayTargetCommand.94eb9a3262a6cda2.json | 5 +++++ ...GatewayTargetCommand.afcce9d3abeac495.json | 5 +++++ ...GatewayTargetCommand.bbf67dd173942336.json | 5 ----- .../GetGatewayCommand.19a4b639326fd0de.json | 19 ------------------- .../GetGatewayCommand.e015bbd46d1859f1.json | 19 +++++++++++++++++++ ...atewayTargetCommand.94eb9a3262a6cda2.json} | 11 +++++------ .../delete/connector-delete.golden.json | 4 ++-- .../delete/gateway-delete.golden.json | 2 +- .../__fixtures__/delete/resources.json | 10 +++++----- .../delete/rule-delete.golden.json | 2 +- .../delete/target-delete.golden.json | 4 ++-- 16 files changed, 53 insertions(+), 54 deletions(-) delete mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.19a4b639326fd0de.json create mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.e015bbd46d1859f1.json delete mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.342327fcfcb605c7.json create mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.cee854fd6fa9fd16.json delete mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.5ecec887ab4a48e6.json create mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.94eb9a3262a6cda2.json create mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.afcce9d3abeac495.json delete mode 100644 src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.bbf67dd173942336.json delete mode 100644 src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.19a4b639326fd0de.json create mode 100644 src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.e015bbd46d1859f1.json rename src/handlers/gateway/__fixtures__/delete/{GetGatewayTargetCommand.5ecec887ab4a48e6.json => GetGatewayTargetCommand.94eb9a3262a6cda2.json} (62%) diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.19a4b639326fd0de.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.19a4b639326fd0de.json deleted file mode 100644 index 993eebcf7..000000000 --- a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.19a4b639326fd0de.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "gatewayId": "agentcore-cli-gateway-delete-fixture-ezjljj9oro", - "status": "DELETING" -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.e015bbd46d1859f1.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.e015bbd46d1859f1.json new file mode 100644 index 000000000..a7fa04545 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.e015bbd46d1859f1.json @@ -0,0 +1,4 @@ +{ + "gatewayId": "agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.342327fcfcb605c7.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.342327fcfcb605c7.json deleted file mode 100644 index 8a623ead3..000000000 --- a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.342327fcfcb605c7.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "ruleId": "de4dd841-9a60-40bd-b98e-a2051cfddd5b", - "status": "DELETING" -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.cee854fd6fa9fd16.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.cee854fd6fa9fd16.json new file mode 100644 index 000000000..1b1f7d63b --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.cee854fd6fa9fd16.json @@ -0,0 +1,4 @@ +{ + "ruleId": "80e073e4-eb05-4371-8e6a-f4bde522699c", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.5ecec887ab4a48e6.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.5ecec887ab4a48e6.json deleted file mode 100644 index aaaf68c16..000000000 --- a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.5ecec887ab4a48e6.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", - "targetId": "ARDOBZFUWC", - "status": "DELETING" -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.94eb9a3262a6cda2.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.94eb9a3262a6cda2.json new file mode 100644 index 000000000..1cbd45898 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.94eb9a3262a6cda2.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "U9OM2R9I8Q", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.afcce9d3abeac495.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.afcce9d3abeac495.json new file mode 100644 index 000000000..deb34f170 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.afcce9d3abeac495.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "JYNNGDZ42F", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.bbf67dd173942336.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.bbf67dd173942336.json deleted file mode 100644 index c964e2fa8..000000000 --- a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.bbf67dd173942336.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", - "targetId": "JSO2GGRAMS", - "status": "DELETING" -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.19a4b639326fd0de.json b/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.19a4b639326fd0de.json deleted file mode 100644 index 3aaae8486..000000000 --- a/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.19a4b639326fd0de.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", - "gatewayId": "agentcore-cli-gateway-delete-fixture-ezjljj9oro", - "createdAt": { - "$date": "2026-08-13T14:14:06.744Z" - }, - "updatedAt": { - "$date": "2026-08-13T14:14:07.551Z" - }, - "status": "READY", - "name": "agentcore-cli-gateway-delete-fixture", - "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-delete-fixture-ezjljj9oro.gateway.bedrock-agentcore.us-east-1.amazonaws.com", - "description": "Disposable Gateway Delete fixture", - "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGatewayDeleteFixtureRole", - "workloadIdentityDetails": { - "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-delete-fixture-ezjljj9oro" - } -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.e015bbd46d1859f1.json b/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.e015bbd46d1859f1.json new file mode 100644 index 000000000..fed4210d2 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.e015bbd46d1859f1.json @@ -0,0 +1,19 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "gatewayId": "agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "createdAt": { + "$date": "2026-08-07T17:54:42.000Z" + }, + "updatedAt": { + "$date": "2026-08-07T17:54:43.000Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-delete-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-delete-fixture-oiemu02wfc.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Delete fixture", + "roleArn": "arn:aws:iam::685197708687:role/AgentCoreCliGatewayDeleteFixtureRole", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-delete-fixture-oiemu02wfc" + } +} diff --git a/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.5ecec887ab4a48e6.json b/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.94eb9a3262a6cda2.json similarity index 62% rename from src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.5ecec887ab4a48e6.json rename to src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.94eb9a3262a6cda2.json index 5c8f2f884..b824529b7 100644 --- a/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.5ecec887ab4a48e6.json +++ b/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.94eb9a3262a6cda2.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", - "targetId": "ARDOBZFUWC", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "U9OM2R9I8Q", "createdAt": { - "$date": "2026-08-13T14:14:09.417Z" + "$date": "2026-08-07T17:54:45.522Z" }, "updatedAt": { - "$date": "2026-08-13T14:14:10.049Z" + "$date": "2026-08-07T17:54:46.453Z" }, "status": "READY", "name": "web-search-delete-fixture", @@ -13,8 +13,7 @@ "mcp": { "connector": { "source": { - "connectorId": "web-search", - "version": "1.1.0" + "connectorId": "web-search" }, "configurations": [ { diff --git a/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json index aaaf68c16..1cbd45898 100644 --- a/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json +++ b/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json @@ -1,5 +1,5 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", - "targetId": "ARDOBZFUWC", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "U9OM2R9I8Q", "status": "DELETING" } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json index 993eebcf7..a7fa04545 100644 --- a/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json +++ b/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json @@ -1,4 +1,4 @@ { - "gatewayId": "agentcore-cli-gateway-delete-fixture-ezjljj9oro", + "gatewayId": "agentcore-cli-gateway-delete-fixture-oiemu02wfc", "status": "DELETING" } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/resources.json b/src/handlers/gateway/__fixtures__/delete/resources.json index c86a165ab..fa612366a 100644 --- a/src/handlers/gateway/__fixtures__/delete/resources.json +++ b/src/handlers/gateway/__fixtures__/delete/resources.json @@ -1,7 +1,7 @@ { - "gatewayId": "agentcore-cli-gateway-delete-fixture-ezjljj9oro", - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", - "targetId": "JSO2GGRAMS", - "connectorId": "ARDOBZFUWC", - "ruleId": "de4dd841-9a60-40bd-b98e-a2051cfddd5b" + "gatewayId": "agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "JYNNGDZ42F", + "connectorId": "U9OM2R9I8Q", + "ruleId": "80e073e4-eb05-4371-8e6a-f4bde522699c" } diff --git a/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json index 8a623ead3..1b1f7d63b 100644 --- a/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json +++ b/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json @@ -1,4 +1,4 @@ { - "ruleId": "de4dd841-9a60-40bd-b98e-a2051cfddd5b", + "ruleId": "80e073e4-eb05-4371-8e6a-f4bde522699c", "status": "DELETING" } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json index c964e2fa8..deb34f170 100644 --- a/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json +++ b/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json @@ -1,5 +1,5 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-ezjljj9oro", - "targetId": "JSO2GGRAMS", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "JYNNGDZ42F", "status": "DELETING" } \ No newline at end of file From 03a78dcaa41d3570e6407870e7a74b580c798136 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 17:10:24 +0000 Subject: [PATCH 6/9] fix(gateway): harden execution role reconciliation --- src/core/gateway.test.ts | 231 +++++++++++++++- src/core/gateway.tsx | 246 +++++++++++++----- src/core/gatewayExecutionRole.test.ts | 153 ++++++++++- src/core/gatewayExecutionRole.ts | 115 +++++++- src/core/gatewayPolicy.test.ts | 26 +- src/core/gatewayPolicy.ts | 7 +- .../gateway/connector/create/index.tsx | 10 +- src/handlers/gateway/gateway.create.test.tsx | 55 ++++ src/handlers/gateway/target/create/index.tsx | 10 +- src/handlers/gateway/types.tsx | 4 +- 10 files changed, 748 insertions(+), 109 deletions(-) diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index 2facf7224..89ae4ba3b 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -14,12 +14,15 @@ import { UpdateGatewayCommand, UpdateGatewayTargetCommand, type BedrockAgentCoreControlClient, + type CredentialProviderConfiguration, type GetGatewayResponse, type GetGatewayTargetResponse, type TargetSummary, } from "@aws-sdk/client-bedrock-agentcore-control"; import { CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, GetRoleCommand, PutRolePolicyCommand, type IAMClient, @@ -28,6 +31,7 @@ import { ERROR_SOURCE, ResultTruncationError } from "../errors"; import type { GatewayTargetUpdatePatch, GatewayUpdatePatch } from "../handlers/gateway/types"; import type { AwsClients } from "./types"; import { GatewayClient } from "./gateway"; +import { GatewayMutationIndeterminateError } from "./gatewayExecutionRole"; const options = { region: "us-west-2", endpointUrl: "https://agentcore.example.test" }; @@ -238,6 +242,22 @@ describe("GatewayClient Connector facade", () => { }); const OPTIONS = { region: "us-west-2" }; +const MANAGED_ROLE_NAME = "AgentCoreCliGateway-us-west-2-orders"; +const MANAGED_ROLE_ARN = `arn:aws:iam::123456789012:role/${MANAGED_ROLE_NAME}`; +const MANAGED_ROLE_TAGS = [ + { Key: "AgentCoreCLIManaged", Value: "true" }, + { Key: "AgentCoreCLIResourceType", Value: "Gateway" }, + { Key: "AgentCoreCLIRegion", Value: OPTIONS.region }, + { Key: "AgentCoreCLIResourceName", Value: "orders" }, +]; + +function managedRole() { + return { + Arn: MANAGED_ROLE_ARN, + RoleName: MANAGED_ROLE_NAME, + Tags: MANAGED_ROLE_TAGS, + }; +} test("creates a Gateway execution role when no role ARN is supplied", async () => { const controlCommands: unknown[] = []; @@ -266,8 +286,8 @@ test("creates a Gateway execution role when no role ARN is supplied", async () = } return { Role: { - RoleName: "AgentCoreCliGateway-orders", - Arn: "arn:aws:iam::123456789012:role/AgentCoreCliGateway-orders", + RoleName: MANAGED_ROLE_NAME, + Arn: MANAGED_ROLE_ARN, }, }; }, @@ -281,10 +301,12 @@ test("creates a Gateway execution role when no role ARN is supplied", async () = expect(iamCommands[0]).toBeInstanceOf(GetRoleCommand); expect(iamCommands[1]).toBeInstanceOf(CreateRoleCommand); + expect((iamCommands[1] as CreateRoleCommand).input).toMatchObject({ + RoleName: MANAGED_ROLE_NAME, + Tags: MANAGED_ROLE_TAGS, + }); expect(controlCommands[0]).toBeInstanceOf(CreateGatewayCommand); - expect((controlCommands[0] as CreateGatewayCommand).input.roleArn).toBe( - "arn:aws:iam::123456789012:role/AgentCoreCliGateway-orders", - ); + expect((controlCommands[0] as CreateGatewayCommand).input.roleArn).toBe(MANAGED_ROLE_ARN); }); test("stages a Lambda grant without dropping existing target and auth grants", async () => { @@ -293,7 +315,7 @@ test("stages a Lambda grant without dropping existing target and auth grants", a const secretArn = "arn:aws:secretsmanager:us-west-2:123456789012:secret:orders"; const gatewayResponse = { ...gateway(), - roleArn: "arn:aws:iam::123456789012:role/AgentCoreCliGateway-orders", + roleArn: MANAGED_ROLE_ARN, workloadIdentityDetails: { workloadIdentityArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default/workload-identity/orders", @@ -366,7 +388,8 @@ test("stages a Lambda grant without dropping existing target and auth grants", a }) as unknown as BedrockAgentCoreControlClient, iam: () => ({ - send: async (command: PutRolePolicyCommand) => { + send: async (command: GetRoleCommand | PutRolePolicyCommand) => { + if (command instanceof GetRoleCommand) return { Role: managedRole() }; policies.push(JSON.parse(command.input.PolicyDocument!)); return {}; }, @@ -392,6 +415,190 @@ test("stages a Lambda grant without dropping existing target and auth grants", a expect(policies).toHaveLength(1); }); +test("bypasses policy discovery when Target create skips role updates", async () => { + const commands: unknown[] = []; + const clients = { + control: () => + ({ + send: async (command: unknown) => { + commands.push(command); + return { targetId: "target-1" }; + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => { + throw new Error("unexpected IAM client"); + }, + } as unknown as AwsClients; + + await new GatewayClient(clients).createGatewayTarget( + { + gatewayIdentifier: "gateway-1", + name: "calendar", + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.test/mcp" } }, + }, + skipRolePolicyUpdate: true, + }, + OPTIONS, + ); + + expect(commands).toHaveLength(1); + expect(commands[0]).toBeInstanceOf(CreateGatewayTargetCommand); + expect((commands[0] as CreateGatewayTargetCommand).input).not.toHaveProperty( + "skipRolePolicyUpdate", + ); +}); + +test("preserves a newly created role when Gateway mutation outcome is indeterminate", async () => { + const iamCommands: unknown[] = []; + const clients = { + control: () => + ({ + send: async () => { + throw new Error("connection reset"); + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (command: unknown) => { + iamCommands.push(command); + if (command instanceof GetRoleCommand) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { Role: { Arn: MANAGED_ROLE_ARN, RoleName: MANAGED_ROLE_NAME } }; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + + await expect( + new GatewayClient(clients, { propagationDelayMs: 0 }).createGateway( + { name: "orders", authorizerType: "NONE" }, + OPTIONS, + ), + ).rejects.toBeInstanceOf(GatewayMutationIndeterminateError); + + expect(iamCommands.some((command) => command instanceof DeleteRolePolicyCommand)).toBe(false); + expect(iamCommands.some((command) => command instanceof DeleteRoleCommand)).toBe(false); +}); + +async function updateTargetCredentials( + currentCredentials: CredentialProviderConfiguration[], + desiredCredentials: CredentialProviderConfiguration[] | null, + apiKeySecretSource: "MANAGED" | "EXTERNAL" = "MANAGED", +): Promise { + const providerArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; + const secretArn = "arn:aws:secretsmanager:us-west-2:123456789012:secret:orders"; + const currentTarget = { + ...target(), + credentialProviderConfigurations: currentCredentials, + }; + const gatewayResponse = { + ...gateway(), + roleArn: MANAGED_ROLE_ARN, + workloadIdentityDetails: { + workloadIdentityArn: + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default/workload-identity/orders", + }, + }; + let targetReads = 0; + const policies: unknown[] = []; + const clients = { + control: () => + ({ + send: async (command: unknown) => { + if (command instanceof GetGatewayTargetCommand) { + targetReads += 1; + return targetReads === 1 ? currentTarget : { ...currentTarget, status: "READY" }; + } + if (command instanceof GetGatewayCommand) return gatewayResponse; + if (command instanceof ListGatewayTargetsCommand) { + return { items: [{ targetId: currentTarget.targetId }] }; + } + if (command instanceof GetApiKeyCredentialProviderCommand) { + return { + credentialProviderArn: providerArn, + apiKeySecretArn: { secretArn }, + apiKeySecretSource, + }; + } + if (command instanceof GetTokenVaultCommand) { + return { + tokenVaultId: "default", + kmsConfiguration: { + keyType: "CustomerManagedKey", + kmsKeyArn: "arn:aws:kms:us-west-2:123456789012:key/token-vault", + }, + }; + } + if (command instanceof UpdateGatewayTargetCommand) return { targetId: "target-1" }; + throw new Error(`unexpected command ${command}`); + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (command: GetRoleCommand | PutRolePolicyCommand) => { + if (command instanceof GetRoleCommand) return { Role: managedRole() }; + policies.push(JSON.parse(command.input.PolicyDocument!)); + return {}; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + + await new GatewayClient(clients, { propagationDelayMs: 0 }).updateGatewayTarget( + { + gatewayId: "gateway-1", + targetId: "target-1", + credentialProviderConfigurations: desiredCredentials, + }, + OPTIONS, + ); + return policies; +} + +test("adds credential grants when a Target changes from JWT passthrough to API key", async () => { + const providerArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; + const policies = await updateTargetCredentials( + [{ credentialProviderType: "JWT_PASSTHROUGH" }], + [ + { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { providerArn }, + }, + }, + ], + ); + + expect(policies).toHaveLength(2); + expect(JSON.stringify(policies.at(-1))).toContain("GetResourceApiKey"); + expect(JSON.stringify(policies.at(-1))).toContain("secretsmanager:GetSecretValue"); + expect(JSON.stringify(policies.at(-1))).toContain("kms:Decrypt"); +}); + +test("rejects external API-key secrets before mutating a managed Target", async () => { + const providerArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; + + await expect( + updateTargetCredentials( + [{ credentialProviderType: "JWT_PASSTHROUGH" }], + [ + { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { providerArn }, + }, + }, + ], + "EXTERNAL", + ), + ).rejects.toThrow(/--skip-role-policy-update/); +}); + function gateway(): GetGatewayResponse { return { gatewayId: "gateway-1", @@ -522,7 +729,7 @@ describe("GatewayClient updateGateway", () => { const current = { ...gateway(), gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", - roleArn: "arn:aws:iam::123456789012:role/AgentCoreCliGateway-orders", + roleArn: MANAGED_ROLE_ARN, policyEngineConfiguration: undefined, }; const clients = { @@ -540,7 +747,11 @@ describe("GatewayClient updateGateway", () => { }) as unknown as BedrockAgentCoreControlClient, iam: () => ({ - send: async (_command: PutRolePolicyCommand) => { + send: async (command: GetRoleCommand | PutRolePolicyCommand) => { + if (command instanceof GetRoleCommand) { + order.push("role"); + return { Role: managedRole() }; + } order.push("policy"); return {}; }, @@ -559,7 +770,7 @@ describe("GatewayClient updateGateway", () => { OPTIONS, ); - expect(order).toEqual(["get", "policy", "update", "get"]); + expect(order).toEqual(["get", "role", "policy", "update", "get"]); }); test("clears requested fields and merges a Policy Engine mode change", async () => { diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 838a5c874..42428baad 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -56,7 +56,9 @@ import type { import type { AwsClients, CoreOptions } from "./types"; import { GatewayExecutionRole, - isGatewayExecutionRole, + GatewayMutationIndeterminateError, + GatewayMutationTerminalError, + matchesGatewayExecutionRole, type GatewayExecutionRoleOptions, } from "./gatewayExecutionRole"; import { gatewayPolicy, type GatewayPolicyStatement } from "./gatewayPolicy"; @@ -77,6 +79,23 @@ type GatewayCredentialState = { tokenVaultKmsKeyArn?: string; }; +type GatewayPolicyTargetState = Pick< + GetGatewayTargetResponse, + "targetConfiguration" | "credentialProviderConfigurations" +> & { + targetId?: GetGatewayTargetResponse["targetId"]; +}; + +type GatewayFamilyState = { + gateway: GetGatewayResponse; + targets: readonly GatewayPolicyTargetState[]; +}; + +type GatewayPolicySnapshots = { + current: GatewayPolicyStatement[]; + desired: GatewayPolicyStatement[]; +}; + export class GatewayClient implements CoreGatewayClient { constructor( private readonly clients: AwsClients, @@ -90,7 +109,7 @@ export class GatewayClient implements CoreGatewayClient { const gateway = await this.getGateway(gatewayId, options); const name = GatewayClient.required(gateway.name, "Gateway", "name"); const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); - return isGatewayExecutionRole(name, roleArn) ? undefined : roleArn; + return (await this.managedExecutionRole(name, roleArn, options)) ? undefined : roleArn; } async createGateway( @@ -109,8 +128,10 @@ export class GatewayClient implements CoreGatewayClient { ); if (roleArn) return operation(roleArn); const roleManager = this.executionRole(options); - const role = await roleManager.ensure(request.name!); + const role = await roleManager.ensure(request.name!, options.region); let response: CreateGatewayResponse; + let createdResponse: CreateGatewayResponse | undefined; + let mutationAccepted = false; try { const staged = gatewayPolicy({ policyEngineArn: request.policyEngineConfiguration?.arn, @@ -121,16 +142,34 @@ export class GatewayClient implements CoreGatewayClient { role.arn, current, staged, - async () => { - const created = await operation(role.arn); - const gatewayId = GatewayClient.required(created.gatewayId, "Created Gateway", "ID"); - await this.waitForGateway(gatewayId, options); - return created; + { + mutate: async () => { + const created = await this.mutate( + () => operation(role.arn), + `Gateway "${request.name}"`, + ); + mutationAccepted = true; + createdResponse = created; + return created; + }, + stabilize: async () => { + const gatewayId = GatewayClient.required( + createdResponse?.gatewayId, + "Created Gateway", + "ID", + ); + await this.waitForGateway(gatewayId, options); + }, }, { forcePropagation: role.created }, ); } catch (error) { - await roleManager.rollbackCreate(role); + if ( + error instanceof GatewayMutationTerminalError || + (!mutationAccepted && !(error instanceof GatewayMutationIndeterminateError)) + ) { + await roleManager.rollbackCreate(role); + } throw error; } const gatewayArn = GatewayClient.required(response.gatewayArn, "Created Gateway", "ARN"); @@ -222,9 +261,13 @@ export class GatewayClient implements CoreGatewayClient { const operation = () => control.send(new UpdateGatewayCommand(request)); if (patch.roleArn) { const response = await operation(); - if (patch.roleArn !== roleArn && isGatewayExecutionRole(name, roleArn)) { + const roleManager = + patch.roleArn !== roleArn + ? await this.managedExecutionRole(name, roleArn, options) + : undefined; + if (roleManager) { await this.waitForGateway(patch.id, options); - await this.executionRole(options).replace(roleArn, []); + await roleManager.replace(roleArn, []); } return response; } @@ -237,25 +280,25 @@ export class GatewayClient implements CoreGatewayClient { return operation(); } - if (!isGatewayExecutionRole(name, roleArn)) return operation(); - const roleManager = this.executionRole(options); + const roleManager = await this.managedExecutionRole(name, roleArn, options); + if (!roleManager) return operation(); const targets = await this.targetInventory(patch.id, options); - const credentials = await this.credentials(targets, options); - const currentPolicy = this.policy(current, targets, credentials); - const desiredPolicy = this.policy( + const snapshots = await this.policySnapshots( + { gateway: current, targets }, { - ...current, - policyEngineConfiguration, - interceptorConfigurations, - customTransformConfiguration, + gateway: { + ...current, + policyEngineConfiguration, + interceptorConfigurations, + customTransformConfiguration, + }, + targets, }, - targets, - credentials, + options, ); - return roleManager.update(roleArn, currentPolicy, desiredPolicy, async () => { - const response = await operation(); - await this.waitForGateway(patch.id, options); - return response; + return roleManager.update(roleArn, snapshots.current, snapshots.desired, { + mutate: () => this.mutate(operation, resource), + stabilize: () => this.waitForGateway(patch.id, options), }); } @@ -275,11 +318,12 @@ export class GatewayClient implements CoreGatewayClient { const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: id })); const name = GatewayClient.required(gateway.name, "Gateway", "name"); const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); - if (!isGatewayExecutionRole(name, roleArn)) return operation(); + const roleManager = await this.managedExecutionRole(name, roleArn, options); + if (!roleManager) return operation(); const response = await operation(); await this.waitForGatewayDeletion(id, options); - await this.executionRole(options).replace(roleArn, []); + await roleManager.replace(roleArn, []); return response; } @@ -316,30 +360,51 @@ export class GatewayClient implements CoreGatewayClient { options: CoreOptions, ): Promise { const control = this.clients.control(toClientConfig(options)); - const operation = () => control.send(new CreateGatewayTargetCommand(input)); + const { skipRolePolicyUpdate, ...request } = input; + const operation = () => control.send(new CreateGatewayTargetCommand(request)); + if (skipRolePolicyUpdate) return operation(); const gateway = await control.send( - new GetGatewayCommand({ gatewayIdentifier: input.gatewayIdentifier }), + new GetGatewayCommand({ gatewayIdentifier: request.gatewayIdentifier }), ); const name = GatewayClient.required(gateway.name, "Gateway", "name"); const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); - if (!isGatewayExecutionRole(name, roleArn)) return operation(); + const roleManager = await this.managedExecutionRole(name, roleArn, options); + if (!roleManager) return operation(); - const targets = await this.targetInventory(input.gatewayIdentifier!, options); + const targets = await this.targetInventory(request.gatewayIdentifier!, options); + const targetConfiguration = GatewayClient.required( + request.targetConfiguration, + "Gateway Target", + "configuration", + ); const desiredTargets = [ ...targets, { - targetConfiguration: input.targetConfiguration, - credentialProviderConfigurations: input.credentialProviderConfigurations, + targetConfiguration, + credentialProviderConfigurations: request.credentialProviderConfigurations, }, ]; - const credentials = await this.credentials(desiredTargets, options); - const current = this.policy(gateway, targets, credentials); - const desired = this.policy(gateway, desiredTargets, credentials); - return this.executionRole(options).update(roleArn, current, desired, async () => { - const response = await operation(); - const targetId = GatewayClient.required(response.targetId, "Created Gateway Target", "ID"); - await this.waitForGatewayTarget(input.gatewayIdentifier!, targetId, options); - return response; + const snapshots = await this.policySnapshots( + { gateway, targets }, + { gateway, targets: desiredTargets }, + options, + ); + let targetId: string | undefined; + return roleManager.update(roleArn, snapshots.current, snapshots.desired, { + mutate: async () => { + const response = await this.mutate( + operation, + `Gateway Target "${request.name ?? "unnamed"}"`, + ); + if (!response.targetId) { + throw new GatewayMutationIndeterminateError( + `Gateway Target "${request.name ?? "unnamed"}"`, + ); + } + targetId = response.targetId; + return response; + }, + stabilize: () => this.waitForGatewayTarget(request.gatewayIdentifier!, targetId!, options), }); } @@ -417,16 +482,14 @@ export class GatewayClient implements CoreGatewayClient { const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: gatewayId })); const name = GatewayClient.required(gateway.name, "Gateway", "name"); const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); - if (!isGatewayExecutionRole(name, roleArn)) return operation(); + const roleManager = await this.managedExecutionRole(name, roleArn, options); + if (!roleManager) return operation(); const response = await operation(); await this.waitForGatewayTargetDeletion(gatewayId, targetId, options); const remaining = await this.targetInventory(gatewayId, options); const credentials = await this.credentials(remaining, options); - await this.executionRole(options).replace( - roleArn, - this.policy(gateway, remaining, credentials), - ); + await roleManager.replace(roleArn, this.policy(gateway, remaining, credentials)); return response; } @@ -555,19 +618,27 @@ export class GatewayClient implements CoreGatewayClient { ); const gatewayName = GatewayClient.required(gateway.name, "Gateway", "name"); const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); - if (!isGatewayExecutionRole(gatewayName, roleArn)) return operation(); + const roleManager = await this.managedExecutionRole(gatewayName, roleArn, options); + if (!roleManager) return operation(); const targets = await this.targetInventory(patch.gatewayId, options, current); const desiredTargets = targets.map((target) => - target.targetId === patch.targetId ? { ...target, targetConfiguration } : target, + target.targetId === patch.targetId + ? { ...target, targetConfiguration, credentialProviderConfigurations } + : target, ); - const credentials = await this.credentials([...targets, ...desiredTargets], options); - const currentPolicy = this.policy(gateway, targets, credentials); - const desiredPolicy = this.policy(gateway, desiredTargets, credentials); - return this.executionRole(options).update(roleArn, currentPolicy, desiredPolicy, async () => { - const response = await operation(); - await this.waitForGatewayTarget(patch.gatewayId, patch.targetId, options); - return response; + const snapshots = await this.policySnapshots( + { gateway, targets }, + { gateway, targets: desiredTargets }, + options, + ); + return roleManager.update(roleArn, snapshots.current, snapshots.desired, { + mutate: () => + this.mutate( + operation, + `Gateway Target "${patch.targetId}" under Gateway "${patch.gatewayId}"`, + ), + stabilize: () => this.waitForGatewayTarget(patch.gatewayId, patch.targetId, options), }); } @@ -595,12 +666,19 @@ export class GatewayClient implements CoreGatewayClient { return new GatewayExecutionRole(this.clients.iam({ region: options.region }), this.roleOptions); } + private async managedExecutionRole( + gatewayName: string, + roleArn: string, + options: CoreOptions, + ): Promise { + if (!matchesGatewayExecutionRole(gatewayName, options.region, roleArn)) return undefined; + const role = this.executionRole(options); + return (await role.isManaged(gatewayName, options.region, roleArn)) ? role : undefined; + } + private policy( gateway: GetGatewayResponse, - targets: readonly Pick< - GetGatewayTargetResponse, - "targetConfiguration" | "credentialProviderConfigurations" - >[], + targets: readonly GatewayPolicyTargetState[], credentials: GatewayCredentialState = { secrets: new Map() }, ): GatewayPolicyStatement[] { return gatewayPolicy({ @@ -622,8 +700,20 @@ export class GatewayClient implements CoreGatewayClient { }); } + private async policySnapshots( + current: GatewayFamilyState, + desired: GatewayFamilyState, + options: CoreOptions, + ): Promise { + const credentials = await this.credentials([...current.targets, ...desired.targets], options); + return { + current: this.policy(current.gateway, current.targets, credentials), + desired: this.policy(desired.gateway, desired.targets, credentials), + }; + } + private async credentials( - targets: readonly Pick[], + targets: readonly Pick[], options: CoreOptions, ): Promise { const providers = new Map(); @@ -659,6 +749,11 @@ export class GatewayClient implements CoreGatewayClient { if (response.credentialProviderArn !== providerArn) { throw new Error(`Credential provider ${name} returned an unexpected ARN`); } + if ("apiKeySecretSource" in response && response.apiKeySecretSource === "EXTERNAL") { + throw new InputValidationError( + `API key credential provider ${providerArn} uses an external secret; rerun with --skip-role-policy-update and manage its secret and KMS permissions externally`, + ); + } const secretArn = "apiKeySecretArn" in response ? response.apiKeySecretArn?.secretArn @@ -708,6 +803,19 @@ export class GatewayClient implements CoreGatewayClient { ); } + private async mutate(operation: () => Promise, resource: string): Promise { + try { + return await operation(); + } catch (error) { + const statusCode = (error as { $metadata?: { httpStatusCode?: number } }).$metadata + ?.httpStatusCode; + if (statusCode === undefined || statusCode >= 500) { + throw new GatewayMutationIndeterminateError(resource, { cause: error }); + } + throw error; + } + } + private async waitForGateway(gatewayId: string, options: CoreOptions): Promise { await this.waitForTerminal( `Gateway "${gatewayId}"`, @@ -767,20 +875,22 @@ export class GatewayClient implements CoreGatewayClient { const current = await read(); if (current.status && successful.includes(current.status)) return; if (current.status && failed.includes(current.status)) { - throw new AgentCoreCLIError( - `${resource} reached ${current.status}: ${(current.statusReasons ?? []).join(", ")}`, - { source: ERROR_SOURCE.SERVICE }, + throw new GatewayMutationTerminalError( + resource, + current.status, + current.statusReasons ?? [], ); } } catch (error) { - if ((error as Error).name !== "ResourceNotFoundException") throw error; + if (error instanceof GatewayMutationTerminalError) throw error; + if ((error as Error).name !== "ResourceNotFoundException") { + throw new GatewayMutationIndeterminateError(resource, { cause: error }); + } if (missingIsSuccess) return; } if (attempt < attempts - 1) await this.wait(); } - throw new AgentCoreCLIError(`Timed out waiting for ${resource}`, { - source: ERROR_SOURCE.SERVICE, - }); + throw new GatewayMutationIndeterminateError(resource); } private async wait(): Promise { diff --git a/src/core/gatewayExecutionRole.test.ts b/src/core/gatewayExecutionRole.test.ts index 0f7842b66..087206b30 100644 --- a/src/core/gatewayExecutionRole.test.ts +++ b/src/core/gatewayExecutionRole.test.ts @@ -1,9 +1,28 @@ import { describe, expect, test } from "bun:test"; -import { PutRolePolicyCommand, type IAMClient } from "@aws-sdk/client-iam"; -import { GatewayExecutionRole } from "./gatewayExecutionRole"; +import { + CreateRoleCommand, + GetRoleCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import { + GatewayExecutionRole, + GatewayMutationIndeterminateError, + GatewayMutationTerminalError, + gatewayRoleName, + matchesGatewayExecutionRole, +} from "./gatewayExecutionRole"; import type { GatewayPolicyStatement } from "./gatewayPolicy"; -const ROLE_ARN = "arn:aws:iam::123456789012:role/AgentCoreCliGateway-orders"; +const REGION = "us-west-2"; +const ROLE_NAME = "AgentCoreCliGateway-us-west-2-orders"; +const ROLE_ARN = `arn:aws:iam::123456789012:role/${ROLE_NAME}`; +const ROLE_TAGS = [ + { Key: "AgentCoreCLIManaged", Value: "true" }, + { Key: "AgentCoreCLIResourceType", Value: "Gateway" }, + { Key: "AgentCoreCLIRegion", Value: REGION }, + { Key: "AgentCoreCLIResourceName", Value: "orders" }, +]; const current: GatewayPolicyStatement[] = [ { Effect: "Allow", Action: ["lambda:InvokeFunction"], Resource: ["arn:lambda:old"] }, ]; @@ -23,29 +42,145 @@ function policyWrites(): { iam: IAMClient; writes: GatewayPolicyStatement[][] } return { iam, writes }; } +describe("GatewayExecutionRole ownership", () => { + test("creates a region-scoped role with CLI ownership tags", async () => { + const commands: unknown[] = []; + const iam = { + send: async (command: unknown) => { + commands.push(command); + if (command instanceof GetRoleCommand) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { Role: { Arn: ROLE_ARN } }; + }, + } as unknown as IAMClient; + + await expect(new GatewayExecutionRole(iam).ensure("orders", REGION)).resolves.toEqual({ + arn: ROLE_ARN, + name: ROLE_NAME, + created: true, + }); + + expect(gatewayRoleName("orders", REGION)).toBe(ROLE_NAME); + expect((commands[1] as CreateRoleCommand).input).toMatchObject({ + RoleName: ROLE_NAME, + Tags: ROLE_TAGS, + }); + }); + + test("requires both the regional name and ownership tags", async () => { + let calls = 0; + const iam = { + send: async () => { + calls += 1; + return { + Role: { + Arn: ROLE_ARN, + RoleName: ROLE_NAME, + Tags: ROLE_TAGS, + }, + }; + }, + } as unknown as IAMClient; + const role = new GatewayExecutionRole(iam); + + await expect(role.isManaged("orders", REGION, ROLE_ARN)).resolves.toBe(true); + await expect(role.isManaged("orders", "us-east-1", ROLE_ARN)).resolves.toBe(false); + await expect( + new GatewayExecutionRole({ + send: async () => ({ Role: { Arn: ROLE_ARN, RoleName: ROLE_NAME } }), + } as unknown as IAMClient).isManaged("orders", REGION, ROLE_ARN), + ).resolves.toBe(false); + expect(matchesGatewayExecutionRole("orders", REGION, ROLE_ARN)).toBe(true); + expect(calls).toBe(1); + }); + + test("rejects an untagged role that collides with the generated name", async () => { + const iam = { + send: async () => ({ + Role: { Arn: ROLE_ARN, RoleName: ROLE_NAME }, + }), + } as unknown as IAMClient; + + await expect(new GatewayExecutionRole(iam).ensure("orders", REGION)).rejects.toThrow( + /not tagged as managed by the AgentCore CLI/, + ); + }); +}); + describe("GatewayExecutionRole update", () => { test("stages current and desired grants before writing exact desired", async () => { const { iam, writes } = policyWrites(); const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); - await role.update(ROLE_ARN, current, desired, async () => { - expect(writes).toEqual([[...current, ...desired]]); - return "updated"; + await role.update(ROLE_ARN, current, desired, { + mutate: async () => { + expect(writes).toEqual([[...current, ...desired]]); + return "updated"; + }, + stabilize: async () => {}, }); expect(writes).toEqual([[...current, ...desired], desired]); }); - test("restores current grants when the Gateway operation fails", async () => { + test("restores current grants when the mutation is rejected", async () => { const { iam, writes } = policyWrites(); const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); await expect( - role.update(ROLE_ARN, current, desired, async () => { - throw new Error("update failed"); + role.update(ROLE_ARN, current, desired, { + mutate: async () => { + throw new Error("update failed"); + }, + stabilize: async () => {}, }), ).rejects.toThrow("update failed"); expect(writes).toEqual([[...current, ...desired], current]); }); + + test("restores current grants after a terminal service failure", async () => { + const { iam, writes } = policyWrites(); + const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); + + await expect( + role.update(ROLE_ARN, current, desired, { + mutate: async () => "accepted", + stabilize: async () => { + throw new GatewayMutationTerminalError("Gateway", "FAILED", ["invalid"]); + }, + }), + ).rejects.toThrow("Gateway reached FAILED: invalid"); + + expect(writes).toEqual([[...current, ...desired], current]); + }); + + test.each(["mutation", "stabilization"] as const)( + "retains transition grants after indeterminate %s", + async (phase) => { + const { iam, writes } = policyWrites(); + const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); + + await expect( + role.update(ROLE_ARN, current, desired, { + mutate: async () => { + if (phase === "mutation") { + throw new GatewayMutationIndeterminateError("Gateway"); + } + return "accepted"; + }, + stabilize: async () => { + if (phase === "stabilization") { + throw new GatewayMutationIndeterminateError("Gateway"); + } + }, + }), + ).rejects.toBeInstanceOf(GatewayMutationIndeterminateError); + + expect(writes).toEqual([[...current, ...desired]]); + }, + ); }); diff --git a/src/core/gatewayExecutionRole.ts b/src/core/gatewayExecutionRole.ts index d580eb8b0..b31672bc6 100644 --- a/src/core/gatewayExecutionRole.ts +++ b/src/core/gatewayExecutionRole.ts @@ -7,11 +7,20 @@ import { GetRolePolicyCommand, PutRolePolicyCommand, type IAMClient, + type Role, + type Tag, } from "@aws-sdk/client-iam"; +import { AgentCoreCLIError, ERROR_SOURCE } from "../errors"; import type { GatewayPolicyStatement } from "./gatewayPolicy"; const POLICY_NAME = "AgentCoreCliGatewayExecutionPolicy"; const ROLE_PREFIX = "AgentCoreCliGateway-"; +const ROLE_TAGS = { + managed: "AgentCoreCLIManaged", + resourceType: "AgentCoreCLIResourceType", + region: "AgentCoreCLIRegion", + resourceName: "AgentCoreCLIResourceName", +} as const; export type GatewayExecutionRoleOptions = { propagationDelayMs?: number; @@ -24,6 +33,27 @@ export type ManagedGatewayRole = { created: boolean; }; +export class GatewayMutationIndeterminateError extends AgentCoreCLIError { + constructor(resource: string, options?: ErrorOptions) { + super(`The outcome of the ${resource} mutation could not be determined`, { + ...options, + source: ERROR_SOURCE.SERVICE, + }); + } +} + +export class GatewayMutationTerminalError extends AgentCoreCLIError { + constructor( + readonly resource: string, + readonly status: string, + readonly statusReasons: readonly string[], + ) { + super(`${resource} reached ${status}: ${statusReasons.join(", ")}`, { + source: ERROR_SOURCE.SERVICE, + }); + } +} + export class GatewayExecutionRole { private readonly propagationDelayMs: number; private readonly sleep: (milliseconds: number) => Promise; @@ -38,11 +68,16 @@ export class GatewayExecutionRole { ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); } - async ensure(gatewayName: string): Promise { - const roleName = gatewayRoleName(gatewayName); + async ensure(gatewayName: string, region: string): Promise { + const roleName = gatewayRoleName(gatewayName, region); try { const response = await this.iam.send(new GetRoleCommand({ RoleName: roleName })); if (!response.Role?.Arn) throw new Error(`IAM returned no ARN for role ${roleName}`); + if (!isOwnedRole(response.Role, gatewayName, region)) { + throw new Error( + `IAM role ${roleName} already exists but is not tagged as managed by the AgentCore CLI`, + ); + } return { arn: response.Role.Arn, name: roleName, created: false }; } catch (error) { if ((error as Error).name !== "NoSuchEntityException") throw error; @@ -51,6 +86,7 @@ export class GatewayExecutionRole { const response = await this.iam.send( new CreateRoleCommand({ RoleName: roleName, + Tags: gatewayRoleTags(gatewayName, region), AssumeRolePolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: [ @@ -67,6 +103,25 @@ export class GatewayExecutionRole { return { arn: response.Role.Arn, name: roleName, created: true }; } + async isManaged(gatewayName: string, region: string, roleArn: string): Promise { + if (!matchesGatewayExecutionRole(gatewayName, region, roleArn)) return false; + const roleName = roleNameFromArn(roleArn); + + try { + const response = await this.iam.send(new GetRoleCommand({ RoleName: roleName })); + return response.Role?.Arn === roleArn && isOwnedRole(response.Role, gatewayName, region); + } catch (error) { + if ( + ["NoSuchEntityException", "AccessDenied", "AccessDeniedException"].includes( + (error as Error).name, + ) + ) { + return false; + } + throw error; + } + } + async rollbackCreate(role: ManagedGatewayRole): Promise { if (!role.created) return; await this.write(role.name, []); @@ -94,7 +149,10 @@ export class GatewayExecutionRole { roleArn: string, current: GatewayPolicyStatement[], desired: GatewayPolicyStatement[], - operation: () => Promise, + operation: { + mutate: () => Promise; + stabilize: () => Promise; + }, options: { forcePropagation?: boolean } = {}, ): Promise { const roleName = roleNameFromArn(roleArn); @@ -107,9 +165,19 @@ export class GatewayExecutionRole { let value: T; try { - value = await operation(); + value = await operation.mutate(); } catch (error) { - if (staged) await this.write(roleName, current); + if (staged && !(error instanceof GatewayMutationIndeterminateError)) { + await this.write(roleName, current); + } + throw error; + } + try { + await operation.stabilize(); + } catch (error) { + if (staged && error instanceof GatewayMutationTerminalError) { + await this.write(roleName, current); + } throw error; } if (JSON.stringify(transition) !== JSON.stringify(desired)) { @@ -152,15 +220,42 @@ function parsePolicy(document: string): { Statement?: GatewayPolicyStatement[] } } } -export function gatewayRoleName(gatewayName: string): string { - const fullName = `${ROLE_PREFIX}${gatewayName}`; +export function gatewayRoleName(gatewayName: string, region: string): string { + const fullName = `${ROLE_PREFIX}${region}-${gatewayName}`; if (fullName.length <= 64) return fullName; - const hash = createHash("sha256").update(gatewayName).digest("hex").slice(0, 8); + const hash = createHash("sha256").update(`${region}:${gatewayName}`).digest("hex").slice(0, 8); return `${fullName.slice(0, 55)}-${hash}`; } -export function isGatewayExecutionRole(gatewayName: string, roleArn: string): boolean { - return roleNameFromArn(roleArn) === gatewayRoleName(gatewayName); +export function matchesGatewayExecutionRole( + gatewayName: string, + region: string, + roleArn: string, +): boolean { + try { + return roleNameFromArn(roleArn) === gatewayRoleName(gatewayName, region); + } catch { + return false; + } +} + +function gatewayRoleTags(gatewayName: string, region: string): Tag[] { + return [ + { Key: ROLE_TAGS.managed, Value: "true" }, + { Key: ROLE_TAGS.resourceType, Value: "Gateway" }, + { Key: ROLE_TAGS.region, Value: region }, + { Key: ROLE_TAGS.resourceName, Value: gatewayName }, + ]; +} + +function isOwnedRole(role: Role, gatewayName: string, region: string): boolean { + const tags = new Map((role.Tags ?? []).map(({ Key, Value }) => [Key, Value])); + return ( + tags.get(ROLE_TAGS.managed) === "true" && + tags.get(ROLE_TAGS.resourceType) === "Gateway" && + tags.get(ROLE_TAGS.region) === region && + tags.get(ROLE_TAGS.resourceName) === gatewayName + ); } function uniqueStatements(statements: GatewayPolicyStatement[]): GatewayPolicyStatement[] { diff --git a/src/core/gatewayPolicy.test.ts b/src/core/gatewayPolicy.test.ts index 6169a27be..bf60cbdf8 100644 --- a/src/core/gatewayPolicy.test.ts +++ b/src/core/gatewayPolicy.test.ts @@ -181,11 +181,6 @@ test("builds exact grants for every inferable Gateway permission family", () => Action: ["bedrock-agentcore:AuthorizeAction", "bedrock-agentcore:PartiallyAuthorizeActions"], Resource: [ENGINE_ARN, GATEWAY_ARN], }, - { - Effect: "Allow", - Action: ["kms:Decrypt"], - Resource: [TOKEN_VAULT_KEY_ARN], - }, { Effect: "Allow", Action: ["lambda:InvokeFunction"], @@ -252,6 +247,11 @@ test("builds exact grants for every inferable Gateway permission family", () => Action: ["s3:GetObject"], Resource: ["arn:aws:s3:::schemas/mcp.json"], }, + { + Effect: "Allow", + Action: ["kms:Decrypt"], + Resource: [TOKEN_VAULT_KEY_ARN], + }, { Effect: "Allow", Action: ["bedrock-agentcore:GetWorkloadAccessToken"], @@ -336,3 +336,19 @@ test("stages an account-scoped Gateway wildcard before create returns its ARN", }, ]); }); + +test("omits the Token Vault KMS grant when no Target uses stored credentials", () => { + expect( + gatewayPolicy({ + tokenVaultKmsKeyArn: TOKEN_VAULT_KEY_ARN, + targets: [ + { + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.test/mcp" } }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "JWT_PASSTHROUGH" }], + }, + ], + }), + ).toEqual([]); +}); diff --git a/src/core/gatewayPolicy.ts b/src/core/gatewayPolicy.ts index c6c5cf16f..d88296c80 100644 --- a/src/core/gatewayPolicy.ts +++ b/src/core/gatewayPolicy.ts @@ -54,10 +54,6 @@ export function gatewayPolicy(state: GatewayPolicyState): GatewayPolicyStatement Resource: [state.policyEngineArn, state.gatewayArn ?? gatewayWildcard(state.policyEngineArn)], }); } - if (state.tokenVaultKmsKeyArn) { - statements.push(allow("kms:Decrypt", state.tokenVaultKmsKeyArn)); - } - for (const target of state.targets ?? []) { const configuration = target.targetConfiguration; if (containsUnknown(configuration)) throw unsupported("Gateway Target type is not supported"); @@ -76,6 +72,9 @@ export function gatewayPolicy(state: GatewayPolicyState): GatewayPolicyStatement if (!secretArn) throw unsupported(`Credential provider ${providerArn} was not resolved`); const workloadArns = workloadIdentityResources(state.workloadIdentityArn); const grantType = credential.credentialProvider?.oauthCredentialProvider?.grantType; + if (state.tokenVaultKmsKeyArn) { + statements.push(allow("kms:Decrypt", state.tokenVaultKmsKeyArn)); + } statements.push( { Effect: "Allow", diff --git a/src/handlers/gateway/connector/create/index.tsx b/src/handlers/gateway/connector/create/index.tsx index 8467c1d67..e22d77fcc 100644 --- a/src/handlers/gateway/connector/create/index.tsx +++ b/src/handlers/gateway/connector/create/index.tsx @@ -53,6 +53,7 @@ export const createCreateGatewayConnectorHandler = (core: Core, io: AppIO) => "private endpoint (JSON; inline, file://, or - for stdin)", z.string().optional(), ), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -134,10 +135,17 @@ export const createCreateGatewayConnectorHandler = (core: Core, io: AppIO) => credentialProviderConfigurations, ...(metadataConfiguration ? { metadataConfiguration } : {}), ...(privateEndpoint ? { privateEndpoint } : {}), + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), }; const options = coreOptsFromCtx(ctx); - await warnForGatewayRolePolicyUpdate(core, io, flags["gateway-id"], options); + await warnForGatewayRolePolicyUpdate( + core, + io, + flags["gateway-id"], + options, + flags["skip-role-policy-update"], + ); ctx .require(JsonRendererKey) .renderJson(await core.gateway.createGatewayTarget(input, options)); diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index 894e94021..48d5b6033 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -21,6 +21,7 @@ import { fixtureFactories, isRecording, matchGolden, + TestCoreClient, TestGlobalConfigAccessor, testIO, } from "../../testing"; @@ -66,6 +67,17 @@ async function run(args: string[]): Promise { return io.stdout(); } +async function runWithTestCore(args: string[]): Promise { + const core = new TestCoreClient(); + const root = createRootHandler(core, { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return core; +} + async function pollUntil( args: string[], done: (response: Record) => boolean, @@ -463,6 +475,49 @@ describe("Gateway create validation", () => { }); }); +describe("Gateway create mapping", () => { + test.each([ + { + resource: "Target", + args: [ + "gateway", + "target", + "create", + "--gateway-id", + "gateway-1", + "--name", + "calendar", + "--endpoint", + "https://example.test/mcp", + "--skip-role-policy-update", + ], + }, + { + resource: "Connector", + args: [ + "gateway", + "connector", + "create", + "--gateway-id", + "gateway-1", + "--name", + "search", + "--connector", + "web-search", + "--skip-role-policy-update", + ], + }, + ])("maps --skip-role-policy-update for $resource create", async ({ args }) => { + const core = await runWithTestCore([...args]); + + expect(core.gateway.calls).toHaveLength(1); + expect(core.gateway.calls[0]).toMatchObject({ + method: "createGatewayTarget", + args: [{ skipRolePolicyUpdate: true }, { region: REGION }], + }); + }); +}); + describe("Gateway fixture-backed creates", () => { test( "creates a Gateway, Target, Connector, and Rule through the real Core", diff --git a/src/handlers/gateway/target/create/index.tsx b/src/handlers/gateway/target/create/index.tsx index bd358759a..0b2224cd1 100644 --- a/src/handlers/gateway/target/create/index.tsx +++ b/src/handlers/gateway/target/create/index.tsx @@ -54,6 +54,7 @@ export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => z.string().optional(), ), flag("client-token", "idempotency token", z.string().optional()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -117,10 +118,17 @@ export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => ...(metadataConfiguration ? { metadataConfiguration } : {}), ...(privateEndpoint ? { privateEndpoint } : {}), ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), }; const options = coreOptsFromCtx(ctx); - await warnForGatewayRolePolicyUpdate(core, io, flags["gateway-id"], options); + await warnForGatewayRolePolicyUpdate( + core, + io, + flags["gateway-id"], + options, + flags["skip-role-policy-update"], + ); ctx .require(JsonRendererKey) .renderJson(await core.gateway.createGatewayTarget(input, options)); diff --git a/src/handlers/gateway/types.tsx b/src/handlers/gateway/types.tsx index b51ca14a1..931e55275 100644 --- a/src/handlers/gateway/types.tsx +++ b/src/handlers/gateway/types.tsx @@ -30,7 +30,9 @@ export type CreateGatewayInput = Omit Date: Thu, 13 Aug 2026 17:58:44 +0000 Subject: [PATCH 7/9] fix(gateway): close execution role policy gaps --- src/core/gateway.test.ts | 71 +++++++++++++++++++++++++++------------- src/core/gateway.tsx | 14 ++++---- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index 89ae4ba3b..e47efebf4 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -8,6 +8,7 @@ import { GetApiKeyCredentialProviderCommand, GetGatewayCommand, GetGatewayTargetCommand, + GetOauth2CredentialProviderCommand, GetTokenVaultCommand, ListGatewayTargetsCommand, TargetType, @@ -486,10 +487,12 @@ test("preserves a newly created role when Gateway mutation outcome is indetermin async function updateTargetCredentials( currentCredentials: CredentialProviderConfiguration[], desiredCredentials: CredentialProviderConfiguration[] | null, - apiKeySecretSource: "MANAGED" | "EXTERNAL" = "MANAGED", + secretSource: "MANAGED" | "EXTERNAL" = "MANAGED", ): Promise { - const providerArn = + const apiKeyProviderArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; + const oauthProviderArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/orders"; const secretArn = "arn:aws:secretsmanager:us-west-2:123456789012:secret:orders"; const currentTarget = { ...target(), @@ -519,9 +522,16 @@ async function updateTargetCredentials( } if (command instanceof GetApiKeyCredentialProviderCommand) { return { - credentialProviderArn: providerArn, + credentialProviderArn: apiKeyProviderArn, apiKeySecretArn: { secretArn }, - apiKeySecretSource, + apiKeySecretSource: secretSource, + }; + } + if (command instanceof GetOauth2CredentialProviderCommand) { + return { + credentialProviderArn: oauthProviderArn, + clientSecretArn: { secretArn }, + clientSecretSource: secretSource, }; } if (command instanceof GetTokenVaultCommand) { @@ -579,25 +589,41 @@ test("adds credential grants when a Target changes from JWT passthrough to API k expect(JSON.stringify(policies.at(-1))).toContain("kms:Decrypt"); }); -test("rejects external API-key secrets before mutating a managed Target", async () => { - const providerArn = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; +test.each(["API-key", "OAuth"] as const)( + "rejects external %s secrets before mutating a managed Target", + async (kind) => { + const configuration: CredentialProviderConfiguration = + kind === "API-key" + ? { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { + providerArn: + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders", + }, + }, + } + : { + credentialProviderType: "OAUTH", + credentialProvider: { + oauthCredentialProvider: { + providerArn: + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/orders", + scopes: ["orders.read"], + grantType: "CLIENT_CREDENTIALS", + }, + }, + }; - await expect( - updateTargetCredentials( - [{ credentialProviderType: "JWT_PASSTHROUGH" }], - [ - { - credentialProviderType: "API_KEY", - credentialProvider: { - apiKeyCredentialProvider: { providerArn }, - }, - }, - ], - "EXTERNAL", - ), - ).rejects.toThrow(/--skip-role-policy-update/); -}); + await expect( + updateTargetCredentials( + [{ credentialProviderType: "JWT_PASSTHROUGH" }], + [configuration], + "EXTERNAL", + ), + ).rejects.toThrow(/--skip-role-policy-update/); + }, +); function gateway(): GetGatewayResponse { return { @@ -762,6 +788,7 @@ describe("GatewayClient updateGateway", () => { await client.updateGateway( { id: "gateway-1", + roleArn: MANAGED_ROLE_ARN, policyEngineConfiguration: { arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/engine-2", mode: "ENFORCE", diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 42428baad..f4cca4e57 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -259,12 +259,9 @@ export class GatewayClient implements CoreGatewayClient { wafConfiguration, }; const operation = () => control.send(new UpdateGatewayCommand(request)); - if (patch.roleArn) { + if (patch.roleArn && patch.roleArn !== roleArn) { const response = await operation(); - const roleManager = - patch.roleArn !== roleArn - ? await this.managedExecutionRole(name, roleArn, options) - : undefined; + const roleManager = await this.managedExecutionRole(name, roleArn, options); if (roleManager) { await this.waitForGateway(patch.id, options); await roleManager.replace(roleArn, []); @@ -749,9 +746,12 @@ export class GatewayClient implements CoreGatewayClient { if (response.credentialProviderArn !== providerArn) { throw new Error(`Credential provider ${name} returned an unexpected ARN`); } - if ("apiKeySecretSource" in response && response.apiKeySecretSource === "EXTERNAL") { + if ( + ("apiKeySecretSource" in response && response.apiKeySecretSource === "EXTERNAL") || + ("clientSecretSource" in response && response.clientSecretSource === "EXTERNAL") + ) { throw new InputValidationError( - `API key credential provider ${providerArn} uses an external secret; rerun with --skip-role-policy-update and manage its secret and KMS permissions externally`, + `${kind === "api-key" ? "API key" : "OAuth"} credential provider ${providerArn} uses an external secret; rerun with --skip-role-policy-update and manage its secret and KMS permissions externally`, ); } const secretArn = From 3bc012d1e1f530500608bf107a698bdea4ea922e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 18:20:44 +0000 Subject: [PATCH 8/9] fix(gateway): recover policy reconciliation --- README.md | 8 +++ src/core/gateway.test.ts | 99 +++++++++++++++++++++++++++ src/core/gateway.tsx | 42 ++++++++---- src/core/gatewayExecutionRole.test.ts | 12 ++++ src/core/gatewayExecutionRole.ts | 2 +- 5 files changed, 149 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ac53f4729..4e7c32867 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,14 @@ agentcore gateway rule list agentcore gateway rule get ``` +Gateway execution-role reconciliation supports one mutation at a time per +Gateway. Do not run Gateway, Target, or Connector create, update, or delete +commands concurrently for the same Gateway; the CLI does not provide +cross-process locking. If a Gateway deletion remains indeterminate after +retries, the CLI retains `AgentCoreCliGatewayExecutionPolicy` rather than risk +removing permissions from a live Gateway. After confirming the Gateway is +deleted, remove that inline policy manually from its former execution role. + --- # Architecture & patterns diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index e47efebf4..e247cea25 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -696,6 +696,84 @@ test("maps Gateway, Target, and Rule selectors to their delete commands", async }); }); +test("reconciles stale grants when a Target delete retry finds the Target missing", async () => { + const policies: unknown[] = []; + const clients = { + control: () => + ({ + send: async (command: unknown) => { + if (command instanceof GetGatewayCommand) { + return { ...gateway(), roleArn: MANAGED_ROLE_ARN }; + } + if (command instanceof DeleteGatewayTargetCommand) { + const error = new Error("missing"); + error.name = "ResourceNotFoundException"; + throw error; + } + if (command instanceof ListGatewayTargetsCommand) return { items: [] }; + throw new Error(`unexpected command ${command}`); + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (command: GetRoleCommand | PutRolePolicyCommand) => { + if (command instanceof GetRoleCommand) return { Role: managedRole() }; + policies.push(JSON.parse(command.input.PolicyDocument!)); + return {}; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + + await expect( + new GatewayClient(clients).deleteGatewayTarget("gateway-1", "target-1", OPTIONS), + ).rejects.toMatchObject({ name: "ResourceNotFoundException" }); + + expect(policies).toHaveLength(1); + expect(JSON.stringify(policies[0])).toContain("bedrock-agentcore:InvokeGateway"); +}); + +test("retries an indeterminate Gateway deletion read before preserving its policy", async () => { + let gatewayReads = 0; + const iamCommands: unknown[] = []; + const clients = { + control: () => + ({ + send: async (command: unknown) => { + if (command instanceof GetGatewayCommand) { + gatewayReads += 1; + if (gatewayReads === 1) { + return { ...gateway(), roleArn: MANAGED_ROLE_ARN }; + } + const error = new Error(gatewayReads === 2 ? "throttled" : "missing"); + error.name = gatewayReads === 2 ? "ThrottlingException" : "ResourceNotFoundException"; + throw error; + } + if (command instanceof DeleteGatewayCommand) { + return { gatewayId: "gateway-1", status: "DELETING" }; + } + throw new Error(`unexpected command ${command}`); + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (command: unknown) => { + iamCommands.push(command); + if (command instanceof GetRoleCommand) return { Role: managedRole() }; + return {}; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + + await expect( + new GatewayClient(clients, { waitAttempts: 2, waitDelayMs: 0 }).deleteGateway( + "gateway-1", + OPTIONS, + ), + ).resolves.toMatchObject({ gatewayId: "gateway-1" }); + + expect(iamCommands.some((command) => command instanceof DeleteRolePolicyCommand)).toBe(true); +}); + function recordingGatewayClient(responses: unknown[]): { client: GatewayClient; commands: unknown[]; @@ -750,6 +828,27 @@ async function targetUpdateInput( } describe("GatewayClient updateGateway", () => { + test("does not touch IAM when replacing a role with policy updates skipped", async () => { + const { client, commands } = recordingGatewayClient([ + { ...gateway(), roleArn: MANAGED_ROLE_ARN }, + { gatewayId: "gateway-1" }, + ]); + const replacementRoleArn = "arn:aws:iam::123456789012:role/customer-managed"; + + await client.updateGateway( + { + id: "gateway-1", + roleArn: replacementRoleArn, + skipRolePolicyUpdate: true, + }, + OPTIONS, + ); + + expect(commands).toHaveLength(2); + expect(commands[1]).toBeInstanceOf(UpdateGatewayCommand); + expect((commands[1] as UpdateGatewayCommand).input.roleArn).toBe(replacementRoleArn); + }); + test("stages Policy Engine permissions before updating a CLI-owned Gateway", async () => { const order: string[] = []; const current = { diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index f4cca4e57..003dcf342 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -259,6 +259,7 @@ export class GatewayClient implements CoreGatewayClient { wafConfiguration, }; const operation = () => control.send(new UpdateGatewayCommand(request)); + if (patch.skipRolePolicyUpdate) return operation(); if (patch.roleArn && patch.roleArn !== roleArn) { const response = await operation(); const roleManager = await this.managedExecutionRole(name, roleArn, options); @@ -269,10 +270,9 @@ export class GatewayClient implements CoreGatewayClient { return response; } if ( - patch.skipRolePolicyUpdate || - (patch.policyEngineConfiguration === undefined && - patch.interceptorConfigurations === undefined && - patch.customTransformConfiguration === undefined) + patch.policyEngineConfiguration === undefined && + patch.interceptorConfigurations === undefined && + patch.customTransformConfiguration === undefined ) { return operation(); } @@ -482,11 +482,22 @@ export class GatewayClient implements CoreGatewayClient { const roleManager = await this.managedExecutionRole(name, roleArn, options); if (!roleManager) return operation(); - const response = await operation(); + const reconcile = async () => { + const remaining = await this.targetInventory(gatewayId, options, undefined, targetId); + const credentials = await this.credentials(remaining, options); + await roleManager.replace(roleArn, this.policy(gateway, remaining, credentials)); + }; + + let response: DeleteGatewayTargetResponse; + try { + response = await operation(); + } catch (error) { + if ((error as Error).name !== "ResourceNotFoundException") throw error; + await reconcile(); + throw error; + } await this.waitForGatewayTargetDeletion(gatewayId, targetId, options); - const remaining = await this.targetInventory(gatewayId, options); - const credentials = await this.credentials(remaining, options); - await roleManager.replace(roleArn, this.policy(gateway, remaining, credentials)); + await reconcile(); return response; } @@ -777,6 +788,7 @@ export class GatewayClient implements CoreGatewayClient { gatewayId: string, options: CoreOptions, known?: GetGatewayTargetResponse, + excludedTargetId?: string, ): Promise { const targets: GetGatewayTargetResponse[] = []; let nextToken: string | undefined; @@ -789,6 +801,7 @@ export class GatewayClient implements CoreGatewayClient { ); for (const summary of response.items ?? []) { const targetId = GatewayClient.required(summary.targetId, "Gateway Target", "ID"); + if (targetId === excludedTargetId) continue; targets.push( known?.targetId === targetId ? known @@ -870,9 +883,11 @@ export class GatewayClient implements CoreGatewayClient { missingIsSuccess = false, ): Promise { const attempts = this.roleOptions.waitAttempts ?? DEFAULT_WAIT_ATTEMPTS; + let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt++) { try { const current = await read(); + lastError = undefined; if (current.status && successful.includes(current.status)) return; if (current.status && failed.includes(current.status)) { throw new GatewayMutationTerminalError( @@ -883,14 +898,15 @@ export class GatewayClient implements CoreGatewayClient { } } catch (error) { if (error instanceof GatewayMutationTerminalError) throw error; - if ((error as Error).name !== "ResourceNotFoundException") { - throw new GatewayMutationIndeterminateError(resource, { cause: error }); - } - if (missingIsSuccess) return; + if ((error as Error).name === "ResourceNotFoundException" && missingIsSuccess) return; + lastError = error; } if (attempt < attempts - 1) await this.wait(); } - throw new GatewayMutationIndeterminateError(resource); + throw new GatewayMutationIndeterminateError( + resource, + lastError === undefined ? undefined : { cause: lastError }, + ); } private async wait(): Promise { diff --git a/src/core/gatewayExecutionRole.test.ts b/src/core/gatewayExecutionRole.test.ts index 087206b30..1a7e45ff0 100644 --- a/src/core/gatewayExecutionRole.test.ts +++ b/src/core/gatewayExecutionRole.test.ts @@ -126,6 +126,18 @@ describe("GatewayExecutionRole update", () => { expect(writes).toEqual([[...current, ...desired], desired]); }); + test("rewrites exact desired grants after a successful retry with no state delta", async () => { + const { iam, writes } = policyWrites(); + const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); + + await role.update(ROLE_ARN, desired, desired, { + mutate: async () => "updated", + stabilize: async () => {}, + }); + + expect(writes).toEqual([desired]); + }); + test("restores current grants when the mutation is rejected", async () => { const { iam, writes } = policyWrites(); const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); diff --git a/src/core/gatewayExecutionRole.ts b/src/core/gatewayExecutionRole.ts index b31672bc6..5c8a6428d 100644 --- a/src/core/gatewayExecutionRole.ts +++ b/src/core/gatewayExecutionRole.ts @@ -180,7 +180,7 @@ export class GatewayExecutionRole { } throw error; } - if (JSON.stringify(transition) !== JSON.stringify(desired)) { + if (!staged || JSON.stringify(transition) !== JSON.stringify(desired)) { await this.write(roleName, desired); } return value; From 59013311362c868ad51e10b2f06a7c0c7b41dc18 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 19:04:31 +0000 Subject: [PATCH 9/9] fix(gateway): tighten policy recovery paths --- src/core/gateway.test.ts | 215 +----------------- src/core/gateway.tsx | 15 +- src/core/gatewayExecutionRole.test.ts | 8 +- src/core/gatewayExecutionRole.ts | 4 +- .../gateway/connector/delete/index.tsx | 14 +- src/handlers/gateway/gateway.create.test.tsx | 55 ----- src/handlers/gateway/gateway.delete.test.tsx | 4 +- src/handlers/gateway/gateway.update.test.tsx | 4 - src/handlers/gateway/target/delete/index.tsx | 20 +- src/handlers/gateway/types.tsx | 8 +- src/testing/TestCoreClient.tsx | 6 +- 11 files changed, 52 insertions(+), 301 deletions(-) diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index e247cea25..b12ee39f0 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -1,7 +1,5 @@ import { describe, expect, mock, test } from "bun:test"; import { - CreateGatewayCommand, - CreateGatewayTargetCommand, DeleteGatewayCommand, DeleteGatewayRuleCommand, DeleteGatewayTargetCommand, @@ -21,7 +19,6 @@ import { type TargetSummary, } from "@aws-sdk/client-bedrock-agentcore-control"; import { - CreateRoleCommand, DeleteRoleCommand, DeleteRolePolicyCommand, GetRoleCommand, @@ -260,196 +257,6 @@ function managedRole() { }; } -test("creates a Gateway execution role when no role ARN is supplied", async () => { - const controlCommands: unknown[] = []; - const iamCommands: unknown[] = []; - const clients = { - control: () => - ({ - send: async (command: unknown) => { - controlCommands.push(command); - return command instanceof CreateGatewayCommand - ? { - gatewayId: "gateway-1", - gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", - } - : { gatewayId: "gateway-1", status: "READY" }; - }, - }) as unknown as BedrockAgentCoreControlClient, - iam: () => - ({ - send: async (command: GetRoleCommand | CreateRoleCommand) => { - iamCommands.push(command); - if (command instanceof GetRoleCommand) { - const error = new Error("missing"); - error.name = "NoSuchEntityException"; - throw error; - } - return { - Role: { - RoleName: MANAGED_ROLE_NAME, - Arn: MANAGED_ROLE_ARN, - }, - }; - }, - }) as unknown as IAMClient, - } as unknown as AwsClients; - - await new GatewayClient(clients, { propagationDelayMs: 0 }).createGateway( - { name: "orders", authorizerType: "NONE" }, - OPTIONS, - ); - - expect(iamCommands[0]).toBeInstanceOf(GetRoleCommand); - expect(iamCommands[1]).toBeInstanceOf(CreateRoleCommand); - expect((iamCommands[1] as CreateRoleCommand).input).toMatchObject({ - RoleName: MANAGED_ROLE_NAME, - Tags: MANAGED_ROLE_TAGS, - }); - expect(controlCommands[0]).toBeInstanceOf(CreateGatewayCommand); - expect((controlCommands[0] as CreateGatewayCommand).input.roleArn).toBe(MANAGED_ROLE_ARN); -}); - -test("stages a Lambda grant without dropping existing target and auth grants", async () => { - const providerArn = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; - const secretArn = "arn:aws:secretsmanager:us-west-2:123456789012:secret:orders"; - const gatewayResponse = { - ...gateway(), - roleArn: MANAGED_ROLE_ARN, - workloadIdentityDetails: { - workloadIdentityArn: - "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default/workload-identity/orders", - }, - }; - const existingTarget = { - targetId: "web-search", - targetConfiguration: { - mcp: { connector: { source: { connectorId: "web-search" } } }, - }, - } as GetGatewayTargetResponse; - const authenticatedTarget = { - targetId: "authenticated", - targetConfiguration: { - mcp: { mcpServer: { endpoint: "https://example.test/mcp" } }, - }, - credentialProviderConfigurations: [ - { - credentialProviderType: "API_KEY", - credentialProvider: { - apiKeyCredentialProvider: { providerArn }, - }, - }, - ], - } as GetGatewayTargetResponse; - const policies: unknown[] = []; - const clients = { - control: () => - ({ - send: async (command: unknown) => { - if (command instanceof GetGatewayCommand) return gatewayResponse; - if (command instanceof ListGatewayTargetsCommand) { - return { - items: [ - { targetId: existingTarget.targetId }, - { targetId: authenticatedTarget.targetId }, - ], - }; - } - if (command instanceof GetGatewayTargetCommand) { - if (command.input.targetId === existingTarget.targetId) return existingTarget; - if (command.input.targetId === authenticatedTarget.targetId) { - return authenticatedTarget; - } - return { targetId: "lambda", status: "READY" }; - } - if (command instanceof GetApiKeyCredentialProviderCommand) { - expect(command.input.name).toBe("orders"); - return { - credentialProviderArn: providerArn, - apiKeySecretArn: { secretArn }, - }; - } - if (command instanceof GetTokenVaultCommand) { - return { - tokenVaultId: "default", - kmsConfiguration: { keyType: "ServiceManagedKey" }, - }; - } - if (command instanceof CreateGatewayTargetCommand) { - expect(JSON.stringify(policies.at(-1))).toContain( - "arn:aws:lambda:us-west-2:123456789012:function:orders", - ); - expect(JSON.stringify(policies.at(-1))).toContain("InvokeWebSearch"); - expect(JSON.stringify(policies.at(-1))).toContain(secretArn); - return { targetId: "lambda" }; - } - throw new Error(`unexpected command ${command}`); - }, - }) as unknown as BedrockAgentCoreControlClient, - iam: () => - ({ - send: async (command: GetRoleCommand | PutRolePolicyCommand) => { - if (command instanceof GetRoleCommand) return { Role: managedRole() }; - policies.push(JSON.parse(command.input.PolicyDocument!)); - return {}; - }, - }) as unknown as IAMClient, - } as unknown as AwsClients; - - await new GatewayClient(clients, { propagationDelayMs: 0 }).createGatewayTarget( - { - gatewayIdentifier: "gateway-1", - name: "lambda", - targetConfiguration: { - mcp: { - lambda: { - lambdaArn: "arn:aws:lambda:us-west-2:123456789012:function:orders", - toolSchema: { inlinePayload: [] }, - }, - }, - }, - }, - OPTIONS, - ); - - expect(policies).toHaveLength(1); -}); - -test("bypasses policy discovery when Target create skips role updates", async () => { - const commands: unknown[] = []; - const clients = { - control: () => - ({ - send: async (command: unknown) => { - commands.push(command); - return { targetId: "target-1" }; - }, - }) as unknown as BedrockAgentCoreControlClient, - iam: () => { - throw new Error("unexpected IAM client"); - }, - } as unknown as AwsClients; - - await new GatewayClient(clients).createGatewayTarget( - { - gatewayIdentifier: "gateway-1", - name: "calendar", - targetConfiguration: { - mcp: { mcpServer: { endpoint: "https://example.test/mcp" } }, - }, - skipRolePolicyUpdate: true, - }, - OPTIONS, - ); - - expect(commands).toHaveLength(1); - expect(commands[0]).toBeInstanceOf(CreateGatewayTargetCommand); - expect((commands[0] as CreateGatewayTargetCommand).input).not.toHaveProperty( - "skipRolePolicyUpdate", - ); -}); - test("preserves a newly created role when Gateway mutation outcome is indeterminate", async () => { const iamCommands: unknown[] = []; const clients = { @@ -674,7 +481,7 @@ test("maps Gateway, Target, and Rule selectors to their delete commands", async const { client, commands } = recordingGatewayClient([gateway(), {}, gateway(), {}, {}]); await client.deleteGateway("gateway-1", OPTIONS); - await client.deleteGatewayTarget("gateway-1", "target-1", OPTIONS); + await client.deleteGatewayTarget({ gatewayId: "gateway-1", targetId: "target-1" }, OPTIONS); await client.deleteGatewayRule("gateway-1", "rule-1", OPTIONS); expect(commands).toHaveLength(5); @@ -725,7 +532,10 @@ test("reconciles stale grants when a Target delete retry finds the Target missin } as unknown as AwsClients; await expect( - new GatewayClient(clients).deleteGatewayTarget("gateway-1", "target-1", OPTIONS), + new GatewayClient(clients).deleteGatewayTarget( + { gatewayId: "gateway-1", targetId: "target-1" }, + OPTIONS, + ), ).rejects.toMatchObject({ name: "ResourceNotFoundException" }); expect(policies).toHaveLength(1); @@ -1000,21 +810,6 @@ describe("GatewayClient updateGatewayTarget", () => { }); }); - test("does not echo empty service metadata arrays into an update", async () => { - const input = await targetUpdateInput( - { - gatewayId: "gateway-1", - targetId: "target-1", - description: "after", - }, - { - ...target(), - metadataConfiguration: { allowedRequestHeaders: [] }, - }, - ); - expect(input.metadataConfiguration).toBeUndefined(); - }); - test("rejects endpoint shorthand for a non-MCP-server Target", async () => { const { client } = recordingGatewayClient([ { diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 003dcf342..6527abd76 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -49,6 +49,7 @@ import type { CreateGatewayInput, CreateGatewayRuleInput, CreateGatewayTargetInput, + GatewayTargetDeleteInput, GatewayRuleUpdateInput, GatewayTargetUpdatePatch, GatewayUpdatePatch, @@ -469,24 +470,24 @@ export class GatewayClient implements CoreGatewayClient { } async deleteGatewayTarget( - gatewayId: string, - targetId: string, + input: GatewayTargetDeleteInput, options: CoreOptions, ): Promise { const control = this.clients.control(toClientConfig(options)); + const { gatewayId, targetId, skipRolePolicyUpdate } = input; const request = { gatewayIdentifier: gatewayId, targetId }; const operation = () => control.send(new DeleteGatewayTargetCommand(request)); + if (skipRolePolicyUpdate) return operation(); const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: gatewayId })); const name = GatewayClient.required(gateway.name, "Gateway", "name"); const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); const roleManager = await this.managedExecutionRole(name, roleArn, options); if (!roleManager) return operation(); - const reconcile = async () => { - const remaining = await this.targetInventory(gatewayId, options, undefined, targetId); - const credentials = await this.credentials(remaining, options); - await roleManager.replace(roleArn, this.policy(gateway, remaining, credentials)); - }; + const remaining = await this.targetInventory(gatewayId, options, undefined, targetId); + const credentials = await this.credentials(remaining, options); + const desiredPolicy = this.policy(gateway, remaining, credentials); + const reconcile = () => roleManager.replace(roleArn, desiredPolicy); let response: DeleteGatewayTargetResponse; try { diff --git a/src/core/gatewayExecutionRole.test.ts b/src/core/gatewayExecutionRole.test.ts index 1a7e45ff0..b3ccf6477 100644 --- a/src/core/gatewayExecutionRole.test.ts +++ b/src/core/gatewayExecutionRole.test.ts @@ -143,7 +143,7 @@ describe("GatewayExecutionRole update", () => { const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); await expect( - role.update(ROLE_ARN, current, desired, { + role.update(ROLE_ARN, desired, desired, { mutate: async () => { throw new Error("update failed"); }, @@ -151,7 +151,7 @@ describe("GatewayExecutionRole update", () => { }), ).rejects.toThrow("update failed"); - expect(writes).toEqual([[...current, ...desired], current]); + expect(writes).toEqual([desired]); }); test("restores current grants after a terminal service failure", async () => { @@ -159,7 +159,7 @@ describe("GatewayExecutionRole update", () => { const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); await expect( - role.update(ROLE_ARN, current, desired, { + role.update(ROLE_ARN, desired, desired, { mutate: async () => "accepted", stabilize: async () => { throw new GatewayMutationTerminalError("Gateway", "FAILED", ["invalid"]); @@ -167,7 +167,7 @@ describe("GatewayExecutionRole update", () => { }), ).rejects.toThrow("Gateway reached FAILED: invalid"); - expect(writes).toEqual([[...current, ...desired], current]); + expect(writes).toEqual([desired]); }); test.each(["mutation", "stabilization"] as const)( diff --git a/src/core/gatewayExecutionRole.ts b/src/core/gatewayExecutionRole.ts index 5c8a6428d..01833b1db 100644 --- a/src/core/gatewayExecutionRole.ts +++ b/src/core/gatewayExecutionRole.ts @@ -167,7 +167,7 @@ export class GatewayExecutionRole { try { value = await operation.mutate(); } catch (error) { - if (staged && !(error instanceof GatewayMutationIndeterminateError)) { + if (!(error instanceof GatewayMutationIndeterminateError)) { await this.write(roleName, current); } throw error; @@ -175,7 +175,7 @@ export class GatewayExecutionRole { try { await operation.stabilize(); } catch (error) { - if (staged && error instanceof GatewayMutationTerminalError) { + if (error instanceof GatewayMutationTerminalError) { await this.write(roleName, current); } throw error; diff --git a/src/handlers/gateway/connector/delete/index.tsx b/src/handlers/gateway/connector/delete/index.tsx index 52df6eba2..40e4c1cca 100644 --- a/src/handlers/gateway/connector/delete/index.tsx +++ b/src/handlers/gateway/connector/delete/index.tsx @@ -13,6 +13,7 @@ export const createDeleteGatewayConnectorHandler = (core: Core) => flags: [ flag("gateway-id", "the parent Gateway ID", z.string().optional()), flag("id", "the connector-backed Gateway Target ID", z.string().optional()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -27,8 +28,15 @@ export const createDeleteGatewayConnectorHandler = (core: Core) => if (!GatewayConnectorTarget.is(target.targetConfiguration)) { throw new InputValidationError(`Gateway Target "${flags.id}" is not connector-backed`); } - ctx - .require(JsonRendererKey) - .renderJson(await core.gateway.deleteGatewayTarget(flags["gateway-id"], flags.id, options)); + ctx.require(JsonRendererKey).renderJson( + await core.gateway.deleteGatewayTarget( + { + gatewayId: flags["gateway-id"], + targetId: flags.id, + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), + }, + options, + ), + ); }, }); diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index 48d5b6033..894e94021 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -21,7 +21,6 @@ import { fixtureFactories, isRecording, matchGolden, - TestCoreClient, TestGlobalConfigAccessor, testIO, } from "../../testing"; @@ -67,17 +66,6 @@ async function run(args: string[]): Promise { return io.stdout(); } -async function runWithTestCore(args: string[]): Promise { - const core = new TestCoreClient(); - const root = createRootHandler(core, { - io: testIO().io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - await root.route(["node", "agentcore", ...args, "--region", REGION]); - return core; -} - async function pollUntil( args: string[], done: (response: Record) => boolean, @@ -475,49 +463,6 @@ describe("Gateway create validation", () => { }); }); -describe("Gateway create mapping", () => { - test.each([ - { - resource: "Target", - args: [ - "gateway", - "target", - "create", - "--gateway-id", - "gateway-1", - "--name", - "calendar", - "--endpoint", - "https://example.test/mcp", - "--skip-role-policy-update", - ], - }, - { - resource: "Connector", - args: [ - "gateway", - "connector", - "create", - "--gateway-id", - "gateway-1", - "--name", - "search", - "--connector", - "web-search", - "--skip-role-policy-update", - ], - }, - ])("maps --skip-role-policy-update for $resource create", async ({ args }) => { - const core = await runWithTestCore([...args]); - - expect(core.gateway.calls).toHaveLength(1); - expect(core.gateway.calls[0]).toMatchObject({ - method: "createGatewayTarget", - args: [{ skipRolePolicyUpdate: true }, { region: REGION }], - }); - }); -}); - describe("Gateway fixture-backed creates", () => { test( "creates a Gateway, Target, Connector, and Rule through the real Core", diff --git a/src/handlers/gateway/gateway.delete.test.tsx b/src/handlers/gateway/gateway.delete.test.tsx index 04795a026..2d025441a 100644 --- a/src/handlers/gateway/gateway.delete.test.tsx +++ b/src/handlers/gateway/gateway.delete.test.tsx @@ -85,7 +85,7 @@ describe("gateway delete commands", () => { expect(core.gateway.calls).toEqual([ { method: "deleteGatewayTarget", - args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + args: [{ gatewayId: GATEWAY_ID, targetId: TARGET_ID }, { region: REGION }], }, ]); expect(JSON.parse(result.stdout)).toEqual(response); @@ -115,7 +115,7 @@ describe("gateway delete commands", () => { }, { method: "deleteGatewayTarget", - args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + args: [{ gatewayId: GATEWAY_ID, targetId: TARGET_ID }, { region: REGION }], }, ]); expect(JSON.parse(result.stdout)).toEqual(response); diff --git a/src/handlers/gateway/gateway.update.test.tsx b/src/handlers/gateway/gateway.update.test.tsx index a6bd6bb5d..bbf080aa7 100644 --- a/src/handlers/gateway/gateway.update.test.tsx +++ b/src/handlers/gateway/gateway.update.test.tsx @@ -170,7 +170,6 @@ describe("Gateway update patch mapping", () => { '{"http":{"passthrough":{"endpoint":"https://example.test","protocolType":"CUSTOM"}}}', "--clear-description", "--clear-credential-provider-configurations", - "--skip-role-policy-update", ]); expect( @@ -183,7 +182,6 @@ describe("Gateway update patch mapping", () => { http: { passthrough: { endpoint: "https://example.test", protocolType: "CUSTOM" } }, }, credentialProviderConfigurations: null, - skipRolePolicyUpdate: true, }); }); @@ -198,7 +196,6 @@ describe("Gateway update patch mapping", () => { "target-1", "--connector", "web-search", - "--skip-role-policy-update", ]); expect( @@ -219,7 +216,6 @@ describe("Gateway update patch mapping", () => { }, }, }, - skipRolePolicyUpdate: true, }); }); diff --git a/src/handlers/gateway/target/delete/index.tsx b/src/handlers/gateway/target/delete/index.tsx index 75c3626fb..1575eb6e1 100644 --- a/src/handlers/gateway/target/delete/index.tsx +++ b/src/handlers/gateway/target/delete/index.tsx @@ -12,6 +12,7 @@ export const createDeleteGatewayTargetHandler = (core: Core) => flags: [ flag("gateway-id", "the parent Gateway ID", z.string().optional()), flag("target-id", "the Target ID", z.string().optional()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -20,14 +21,15 @@ export const createDeleteGatewayTargetHandler = (core: Core) => if (!flags["target-id"]) { throw new InputValidationError("required option '--target-id ' not specified"); } - ctx - .require(JsonRendererKey) - .renderJson( - await core.gateway.deleteGatewayTarget( - flags["gateway-id"], - flags["target-id"], - coreOptsFromCtx(ctx), - ), - ); + ctx.require(JsonRendererKey).renderJson( + await core.gateway.deleteGatewayTarget( + { + gatewayId: flags["gateway-id"], + targetId: flags["target-id"], + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), + }, + coreOptsFromCtx(ctx), + ), + ); }, }); diff --git a/src/handlers/gateway/types.tsx b/src/handlers/gateway/types.tsx index 931e55275..e803682b4 100644 --- a/src/handlers/gateway/types.tsx +++ b/src/handlers/gateway/types.tsx @@ -67,6 +67,11 @@ export type GatewayTargetUpdatePatch = { privateEndpoint?: UpdateGatewayTargetRequest["privateEndpoint"] | null; }; +export type GatewayTargetDeleteInput = Pick< + GatewayTargetUpdatePatch, + "gatewayId" | "targetId" | "skipRolePolicyUpdate" +>; + export type GatewayRuleUpdateInput = UpdateGatewayRuleRequest; export interface CoreGatewayClient { @@ -115,8 +120,7 @@ export interface CoreGatewayClient { options: CoreOptions, ): Promise; deleteGatewayTarget( - gatewayId: string, - targetId: string, + input: GatewayTargetDeleteInput, options: CoreOptions, ): Promise; getGatewayRule( diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index e9875d45e..19815176c 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -102,6 +102,7 @@ import type { CreateGatewayInput, CreateGatewayRuleInput, CreateGatewayTargetInput, + GatewayTargetDeleteInput, GatewayRuleUpdateInput, GatewayTargetUpdatePatch, GatewayUpdatePatch, @@ -1075,11 +1076,10 @@ export class TestGatewayClient implements CoreGatewayClient { } async deleteGatewayTarget( - gatewayId: string, - targetId: string, + input: GatewayTargetDeleteInput, options: CoreOptions, ): Promise { - this.calls.push({ method: "deleteGatewayTarget", args: [gatewayId, targetId, options] }); + this.calls.push({ method: "deleteGatewayTarget", args: [input, options] }); if (this.error) throw this.error; return this.deleteTargetResponse; }