From bdeb766163c8ec8047879a113e3141d9f2250fbb Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 12 Aug 2026 22:18:25 +0000 Subject: [PATCH] feat(gateway): reconcile managed execution role policies --- src/core/gateway.tsx | 542 +++++++++++++- src/core/gatewayIam.test.ts | 687 ++++++++++++++++++ src/core/gatewayPolicy.test.ts | 248 +++++++ src/core/gatewayPolicy.ts | 237 ++++++ src/core/gatewayTargetIam.test.ts | 332 +++++++++ src/core/index.tsx | 6 +- ...CreateGatewayCommand.3c6f4fdc422cd75c.json | 17 + ...CreateGatewayCommand.7f678b79455bc400.json | 17 - ...eGatewayRuleCommand.5dbeb94f25f7c964.json} | 6 +- ...atewayTargetCommand.959bf5ffe0c36d0b.json} | 8 +- ...atewayTargetCommand.c36b2aed80b6d481.json} | 8 +- .../CreateRoleCommand.b3e0d9996eba4da7.json | 12 + .../GetGatewayCommand.5ff84cafc95022b4.json | 19 - .../GetGatewayCommand.7209e4c2b10ad15a.json | 19 + ...tGatewayRuleCommand.136772a9fb296e8a.json} | 8 +- ...atewayTargetCommand.890798045a9f2d8c.json} | 8 +- ...atewayTargetCommand.d38d917375a78a69.json} | 11 +- .../GetRoleCommand.fe39e3a34c68009d.json | 15 + ...GetRolePolicyCommand.2b8ca68519eecef2.json | 5 + ...atewayTargetsCommand.cd8ce53f82e9eba8.json | 29 + .../ListGatewaysCommand.a5e0cc47f0a60d0d.json | 3 + ...tRolePoliciesCommand.fe39e3a34c68009d.json | 6 + ...PutRolePolicyCommand.35af5f2a27f4f0d1.json | 1 + ...PutRolePolicyCommand.c908ecb76c92c2d6.json | 1 + .../create/connector-create.golden.json | 8 +- .../create/gateway-create.golden.json | 14 +- .../__fixtures__/create/resources.json | 3 + .../create/rule-create.golden.json | 6 +- .../create/target-create.golden.json | 8 +- .../gateway/connector/create/index.tsx | 26 +- src/handlers/gateway/create/index.tsx | 19 +- src/handlers/gateway/gateway.create.test.tsx | 302 ++++---- src/handlers/gateway/gateway.test.tsx | 80 +- src/handlers/gateway/target/create/index.tsx | 26 +- src/handlers/gateway/types.tsx | 16 +- src/testing/TestCoreClient.tsx | 8 +- src/testing/fixtures.test.tsx | 77 ++ src/testing/fixtures.tsx | 212 +++++- 38 files changed, 2772 insertions(+), 278 deletions(-) create mode 100644 src/core/gatewayIam.test.ts create mode 100644 src/core/gatewayPolicy.test.ts create mode 100644 src/core/gatewayPolicy.ts create mode 100644 src/core/gatewayTargetIam.test.ts create mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.3c6f4fdc422cd75c.json delete mode 100644 src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json rename src/handlers/gateway/__fixtures__/create/{CreateGatewayRuleCommand.d87304453bfe9534.json => CreateGatewayRuleCommand.5dbeb94f25f7c964.json} (55%) rename src/handlers/gateway/__fixtures__/create/{CreateGatewayTargetCommand.22512d9b9027f76a.json => CreateGatewayTargetCommand.959bf5ffe0c36d0b.json} (71%) rename src/handlers/gateway/__fixtures__/create/{CreateGatewayTargetCommand.93c8d0503a636294.json => CreateGatewayTargetCommand.c36b2aed80b6d481.json} (52%) create mode 100644 src/handlers/gateway/__fixtures__/create/CreateRoleCommand.b3e0d9996eba4da7.json delete mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5ff84cafc95022b4.json create mode 100644 src/handlers/gateway/__fixtures__/create/GetGatewayCommand.7209e4c2b10ad15a.json rename src/handlers/gateway/__fixtures__/create/{GetGatewayRuleCommand.9a6bd6a64eebb5df.json => GetGatewayRuleCommand.136772a9fb296e8a.json} (52%) rename src/handlers/gateway/__fixtures__/create/{GetGatewayTargetCommand.91cea07923c15ff.json => GetGatewayTargetCommand.890798045a9f2d8c.json} (52%) rename src/handlers/gateway/__fixtures__/create/{GetGatewayTargetCommand.3855b58e8bc470ab.json => GetGatewayTargetCommand.d38d917375a78a69.json} (65%) create mode 100644 src/handlers/gateway/__fixtures__/create/GetRoleCommand.fe39e3a34c68009d.json create mode 100644 src/handlers/gateway/__fixtures__/create/GetRolePolicyCommand.2b8ca68519eecef2.json create mode 100644 src/handlers/gateway/__fixtures__/create/ListGatewayTargetsCommand.cd8ce53f82e9eba8.json create mode 100644 src/handlers/gateway/__fixtures__/create/ListGatewaysCommand.a5e0cc47f0a60d0d.json create mode 100644 src/handlers/gateway/__fixtures__/create/ListRolePoliciesCommand.fe39e3a34c68009d.json create mode 100644 src/handlers/gateway/__fixtures__/create/PutRolePolicyCommand.35af5f2a27f4f0d1.json create mode 100644 src/handlers/gateway/__fixtures__/create/PutRolePolicyCommand.c908ecb76c92c2d6.json create mode 100644 src/handlers/gateway/__fixtures__/create/resources.json create mode 100644 src/testing/fixtures.test.tsx diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index b1d7764c5..f1fcb79d8 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { CreateGatewayCommand, CreateGatewayRuleCommand, @@ -27,29 +28,191 @@ import type { CreateGatewayInput, CreateGatewayRuleInput, CreateGatewayTargetInput, + GatewayMutationResult, } from "../handlers/gateway/types"; import type { AwsClients, CoreOptions } from "./types"; +import { ExecutionRoleManager, type ExecutionRoleManagerOptions } from "./executionRoleManager"; +import { + ExecutionRolePolicyUpdater, + PolicyFinalizationError, + PolicyOperationOutcomeUnknownError, + type ExecutionRolePolicyUpdaterOptions, +} from "./executionRolePolicyUpdater"; +import { GatewayPolicyPlanner } from "./gatewayPolicy"; import { toClientConfig } from "./utils"; const DEFAULT_CONNECTOR_PAGE_SIZE = 100; const MAX_CONNECTOR_TARGET_PAGES = 101; +const DEFAULT_WAIT_ATTEMPTS = 60; +const DEFAULT_WAIT_DELAY_MS = 2_000; + +export type GatewayClientOptions = { + policyUpdater?: ExecutionRolePolicyUpdaterOptions; + roleManager?: ExecutionRoleManagerOptions; + waitAttempts?: number; + waitDelayMs?: number; + sleep?: (milliseconds: number) => Promise; +}; + +export class GatewayTerminalStateError extends Error { + constructor( + readonly gatewayId: string, + readonly status: string, + readonly statusReasons: readonly string[], + ) { + super( + `Gateway ${gatewayId} reached ${status}` + + (statusReasons.length > 0 ? `: ${statusReasons.join("; ")}` : "."), + ); + this.name = "GatewayTerminalStateError"; + } +} + +export class GatewayTargetTerminalStateError extends Error { + constructor( + readonly gatewayId: string, + readonly targetId: string, + readonly status: string, + readonly statusReasons: readonly string[], + ) { + super( + `Gateway Target ${targetId} under ${gatewayId} reached ${status}` + + (statusReasons.length > 0 ? `: ${statusReasons.join("; ")}` : "."), + ); + this.name = "GatewayTargetTerminalStateError"; + } +} + +export class GatewayOutcomeUnknownError extends Error { + constructor(resource: string, options?: ErrorOptions) { + super(`The final state of ${resource} could not be determined.`, options); + this.name = "GatewayOutcomeUnknownError"; + } +} export class GatewayClient implements CoreGatewayClient { - constructor(private readonly clients: AwsClients) {} + private readonly planner = new GatewayPolicyPlanner(); + private readonly options: GatewayClientOptions; + private readonly sleep: (milliseconds: number) => Promise; + + constructor( + private readonly clients: AwsClients, + options: GatewayClientOptions = {}, + ) { + this.options = options; + this.sleep = options.sleep ?? delay; + } async createGateway( input: CreateGatewayInput, options: CoreOptions, ): 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 protocolType = protocol === "mcp" ? ("MCP" as const) : undefined; + if (roleArn) { + const { response } = await this.createGatewayAndWait( + { + ...request, + clientToken: request.clientToken ?? randomUUID(), + roleArn, + ...(protocolType ? { protocolType } : {}), + }, + options, + ); + return response; + } + + const existingGateway = await this.findGatewayByName(input.name!, options); + if (existingGateway) { + throw new InputValidationError( + `Gateway "${input.name}" already exists as ${existingGateway.gatewayId}.`, + ); + } + const iam = this.clients.iam({ region: options.region }); + const roleManager = new ExecutionRoleManager(iam, this.options.roleManager); + const managedRole = await roleManager.ensureCliRole({ + primitive: "gateway", + resourceName: input.name!, + }); + const policyName = ExecutionRoleManager.generatedPolicyName("gateway", { + accountId: accountIdFromRoleArn(managedRole.arn), + region: options.region, + stableResourceKey: managedRole.name, + }); + const policyUpdater = new ExecutionRolePolicyUpdater(iam, { + propagationDelayMs: 10_000, + ...this.options.policyUpdater, + }); + const clientToken = request.clientToken ?? randomUUID(); + + try { + const result = await policyUpdater.update({ + roleName: managedRole.name, + policyName, + current: [], + inventoryComplete: managedRole.created, + desired: this.planner.plan({ + policyEngineConfiguration: request.policyEngineConfiguration, + interceptorConfigurations: request.interceptorConfigurations, + targets: [], + }), + operation: () => + this.createGatewayAndWait( + { + ...request, + clientToken, + roleArn: managedRole.arn, + ...(protocolType ? { protocolType } : {}), + }, + options, + ), + operationRetry: { + maxAttempts: 8, + delayMs: 2_000, + shouldRetry: isExecutionRolePropagationError, + }, + isOperationOutcomeUnknown: (error) => error instanceof GatewayOutcomeUnknownError, + resolveDesired: async ({ settled }) => ({ + contributions: this.planner.plan( + await this.readGatewayPolicyState(settled.gatewayId!, options, settled).then( + (state) => ({ + gatewayArn: state.gateway.gatewayArn, + policyEngineConfiguration: state.gateway.policyEngineConfiguration, + interceptorConfigurations: state.gateway.interceptorConfigurations, + customTransformConfiguration: state.gateway.customTransformConfiguration, + targets: state.targets, + }), + ), + ), + inventoryComplete: true, + }), + }); + return result.value.response; + } catch (error) { + if ( + error instanceof PolicyFinalizationError || + error instanceof PolicyOperationOutcomeUnknownError + ) { + throw error; + } + return roleManager.rollbackFailedCreate(managedRole, policyName, error); + } + } + + async getGatewayRolePolicyWarning( + gatewayId: string, + options: CoreOptions, + ): Promise<{ reason: "unknown-role"; roleArn: string } | undefined> { + const gateway = await this.getGateway(gatewayId, options); + if (!gateway.roleArn) { + throw new Error(`Gateway ${gatewayId} returned no execution role ARN.`); + } + const management = ExecutionRoleManager.policyManagement({ + associatedRoleArn: gateway.roleArn, + }); + return management.mode === "external" && management.reason === "unknown-role" + ? { reason: "unknown-role", roleArn: management.roleArn } + : undefined; } async getGateway(id: string, options: CoreOptions): Promise { @@ -99,10 +262,145 @@ export class GatewayClient implements CoreGatewayClient { async createGatewayTarget( input: CreateGatewayTargetInput, options: CoreOptions, - ): Promise { - return this.clients - .control(toClientConfig(options)) - .send(new CreateGatewayTargetCommand(input)); + ): Promise> { + const gateway = await this.getGateway(input.gatewayIdentifier!, options); + if (!gateway.roleArn) { + throw new Error(`Gateway ${input.gatewayIdentifier} returned no execution role ARN.`); + } + const management = ExecutionRoleManager.policyManagement({ + associatedRoleArn: gateway.roleArn, + }); + const control = this.clients.control(toClientConfig(options)); + + if (management.mode === "external") { + const request = { + ...input, + clientToken: input.clientToken ?? randomUUID(), + }; + let response: CreateGatewayTargetResponse; + try { + response = await control.send(new CreateGatewayTargetCommand(request)); + } catch (error) { + if (isAmbiguousCreateError(error)) { + throw new GatewayOutcomeUnknownError(`Gateway Target under ${input.gatewayIdentifier}`, { + cause: error, + }); + } + throw error; + } + if (!response.targetId) { + throw new Error("CreateGatewayTarget returned no Target ID."); + } + const settled = await this.waitForGatewayTarget( + input.gatewayIdentifier!, + response.targetId, + options, + ); + return { + response: isPendingAuthorizationStatus(settled.status) + ? ({ ...response, ...settled } as CreateGatewayTargetResponse) + : response, + ...(management.reason === "unknown-role" + ? { + rolePolicyWarning: { + reason: "unknown-role" as const, + roleArn: management.roleArn, + }, + } + : {}), + }; + } + + const currentState = await this.readGatewayPolicyState( + input.gatewayIdentifier!, + options, + gateway, + ); + const iam = this.clients.iam({ region: options.region }); + const roleManager = new ExecutionRoleManager(iam, this.options.roleManager); + await roleManager.validateAgentCoreTrust(management.roleName); + const policyName = ExecutionRoleManager.generatedPolicyName("gateway", { + accountId: accountIdFromRoleArn(management.roleArn), + region: options.region, + stableResourceKey: stableGatewayPolicyKey( + management.roleName, + currentState.gateway.gatewayId ?? input.gatewayIdentifier!, + ), + }); + const current = this.planner.plan({ + gatewayArn: currentState.gateway.gatewayArn, + policyEngineConfiguration: currentState.gateway.policyEngineConfiguration, + interceptorConfigurations: currentState.gateway.interceptorConfigurations, + customTransformConfiguration: currentState.gateway.customTransformConfiguration, + targets: currentState.targets, + }); + const proposedTarget = { + name: input.name, + targetConfiguration: input.targetConfiguration, + credentialProviderConfigurations: input.credentialProviderConfigurations, + }; + const desired = this.planner.plan({ + gatewayArn: currentState.gateway.gatewayArn, + policyEngineConfiguration: currentState.gateway.policyEngineConfiguration, + interceptorConfigurations: currentState.gateway.interceptorConfigurations, + customTransformConfiguration: currentState.gateway.customTransformConfiguration, + targets: [...currentState.targets, proposedTarget], + }); + const policyUpdater = new ExecutionRolePolicyUpdater(iam, { + propagationDelayMs: 10_000, + ...this.options.policyUpdater, + }); + const request = { + ...input, + clientToken: input.clientToken ?? randomUUID(), + }; + + const result = await policyUpdater.update({ + roleName: management.roleName, + policyName, + current, + desired, + operation: async () => { + let response: CreateGatewayTargetResponse; + try { + response = await control.send(new CreateGatewayTargetCommand(request)); + } catch (error) { + if (isExecutionRolePropagationError(error)) throw error; + if (isAmbiguousCreateError(error)) { + throw new GatewayOutcomeUnknownError( + `Gateway Target under ${input.gatewayIdentifier}`, + { cause: error }, + ); + } + throw error; + } + if (!response.targetId) { + throw new Error("CreateGatewayTarget returned no Target ID."); + } + await this.waitForGatewayTarget(input.gatewayIdentifier!, response.targetId, options); + return response; + }, + operationRetry: { + maxAttempts: 8, + delayMs: 2_000, + shouldRetry: isExecutionRolePropagationError, + }, + isOperationOutcomeUnknown: (error) => error instanceof GatewayOutcomeUnknownError, + resolveDesired: async () => { + const settledState = await this.readGatewayPolicyState(input.gatewayIdentifier!, options); + return { + contributions: this.planner.plan({ + gatewayArn: settledState.gateway.gatewayArn, + policyEngineConfiguration: settledState.gateway.policyEngineConfiguration, + interceptorConfigurations: settledState.gateway.interceptorConfigurations, + customTransformConfiguration: settledState.gateway.customTransformConfiguration, + targets: settledState.targets, + }), + inventoryComplete: true, + }; + }, + }); + return { response: result.value }; } async getGatewayConnector( @@ -195,4 +493,222 @@ export class GatewayClient implements CoreGatewayClient { configuration?.inference?.connector !== undefined ); } + + private async waitForGateway( + gatewayId: string, + options: CoreOptions, + ): Promise { + const attempts = this.options.waitAttempts ?? DEFAULT_WAIT_ATTEMPTS; + for (let attempt = 1; attempt <= attempts; attempt++) { + let response: GetGatewayResponse; + try { + response = await this.getGateway(gatewayId, options); + } catch (error) { + if ((error as Error).name === "ResourceNotFoundException" && attempt < attempts) { + await this.sleep(this.options.waitDelayMs ?? DEFAULT_WAIT_DELAY_MS); + continue; + } + throw new GatewayOutcomeUnknownError(`Gateway ${gatewayId}`, { cause: error }); + } + if (response.status === "READY") return response; + if (response.status === "FAILED" || response.status === "UPDATE_UNSUCCESSFUL") { + throw new GatewayTerminalStateError( + gatewayId, + response.status, + response.statusReasons ?? [], + ); + } + if (attempt < attempts) { + await this.sleep(this.options.waitDelayMs ?? DEFAULT_WAIT_DELAY_MS); + } + } + throw new GatewayOutcomeUnknownError(`Gateway ${gatewayId}`); + } + + private async createGatewayAndWait( + input: CreateGatewayCommand["input"], + options: CoreOptions, + ): Promise<{ response: CreateGatewayResponse; settled: GetGatewayResponse }> { + const control = this.clients.control(toClientConfig(options)); + let response: CreateGatewayResponse; + try { + response = await control.send(new CreateGatewayCommand(input)); + } catch (error) { + if (isExecutionRolePropagationError(error)) throw error; + if (!isAmbiguousCreateError(error)) throw error; + const observed = await this.observeGatewayByName(input.name!, input.roleArn!, options); + if (observed) { + return { response: observed as CreateGatewayResponse, settled: observed }; + } + throw new GatewayOutcomeUnknownError(`Gateway ${input.name}`, { cause: error }); + } + if (!response.gatewayId) throw new Error("CreateGateway returned no Gateway ID."); + return { + response, + settled: await this.waitForGateway(response.gatewayId, options), + }; + } + + private async observeGatewayByName( + name: string, + roleArn: string, + options: CoreOptions, + ): Promise { + for (let attempt = 1; attempt <= 5; attempt++) { + try { + let nextToken: string | undefined; + const seenTokens = new Set(); + do { + const page = await this.listGateways(nextToken, 100, options); + const match = (page.items ?? []).find((gateway) => gateway.name === name); + if (match?.gatewayId) { + const observed = await this.waitForGateway(match.gatewayId, options); + return observed.roleArn === roleArn ? observed : undefined; + } + if (page.nextToken && seenTokens.has(page.nextToken)) break; + if (page.nextToken) seenTokens.add(page.nextToken); + nextToken = page.nextToken; + } while (nextToken); + } catch (error) { + if (error instanceof GatewayTerminalStateError) throw error; + } + if (attempt < 5) { + await this.sleep(this.options.waitDelayMs ?? DEFAULT_WAIT_DELAY_MS); + } + } + return undefined; + } + + private async findGatewayByName( + name: string, + options: CoreOptions, + ): Promise<{ gatewayId: string; name?: string } | undefined> { + let nextToken: string | undefined; + const seenTokens = new Set(); + do { + const page = await this.listGateways(nextToken, 100, options); + const match = (page.items ?? []).find( + (gateway) => gateway.name === name && gateway.gatewayId, + ); + if (match?.gatewayId) return { gatewayId: match.gatewayId, name: match.name }; + if (page.nextToken && seenTokens.has(page.nextToken)) break; + if (page.nextToken) seenTokens.add(page.nextToken); + nextToken = page.nextToken; + } while (nextToken); + return undefined; + } + + private async waitForGatewayTarget( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise { + const attempts = this.options.waitAttempts ?? DEFAULT_WAIT_ATTEMPTS; + for (let attempt = 1; attempt <= attempts; attempt++) { + let response: GetGatewayTargetResponse; + try { + response = await this.getGatewayTarget(gatewayId, targetId, options); + } catch (error) { + if ((error as Error).name === "ResourceNotFoundException" && attempt < attempts) { + await this.sleep(this.options.waitDelayMs ?? DEFAULT_WAIT_DELAY_MS); + continue; + } + throw new GatewayOutcomeUnknownError(`Gateway Target ${targetId} under ${gatewayId}`, { + cause: error, + }); + } + const status: string | undefined = response.status; + if (status === "READY" || isPendingAuthorizationStatus(status)) return response; + if ( + status === "FAILED" || + status === "UPDATE_UNSUCCESSFUL" || + status === "SYNCHRONIZE_UNSUCCESSFUL" + ) { + throw new GatewayTargetTerminalStateError( + gatewayId, + targetId, + status, + response.statusReasons ?? [], + ); + } + if (attempt < attempts) { + await this.sleep(this.options.waitDelayMs ?? DEFAULT_WAIT_DELAY_MS); + } + } + throw new GatewayOutcomeUnknownError(`Gateway Target ${targetId} under ${gatewayId}`); + } + + private async readGatewayPolicyState( + gatewayId: string, + options: CoreOptions, + knownGateway?: GetGatewayResponse, + ): Promise<{ + gateway: GetGatewayResponse; + targets: GetGatewayTargetResponse[]; + }> { + const gateway = knownGateway ?? (await this.getGateway(gatewayId, options)); + const targets: GetGatewayTargetResponse[] = []; + const seenTokens = new Set(); + let nextToken: string | undefined; + + do { + const page = await this.listGatewayTargets(gatewayId, nextToken, 100, options); + for (const summary of page.items ?? []) { + if (!summary.targetId) + throw new Error(`Gateway ${gatewayId} returned a Target without ID.`); + targets.push(await this.getGatewayTarget(gatewayId, summary.targetId, options)); + } + if (page.nextToken && seenTokens.has(page.nextToken)) { + throw new Error(`Gateway ${gatewayId} repeated Target pagination token.`); + } + if (page.nextToken) seenTokens.add(page.nextToken); + nextToken = page.nextToken; + } while (nextToken); + + return { gateway, targets }; + } +} + +function accountIdFromRoleArn(roleArn: string): string { + const accountId = roleArn.split(":")[4]; + if (!accountId) throw new Error(`Cannot extract account ID from role ARN "${roleArn}".`); + return accountId; +} + +function isExecutionRolePropagationError(error: unknown): boolean { + return ( + ["ValidationException", "AccessDeniedException"].includes((error as Error).name) && + /role|permission|authoriz|assum|policy engine|lambda|invoke/i.test( + (error as Error).message ?? "", + ) + ); +} + +function isAmbiguousCreateError(error: unknown): boolean { + const name = (error as Error).name; + const statusCode = ( + error as { + $metadata?: { httpStatusCode?: number }; + } + ).$metadata?.httpStatusCode; + return ( + ["TimeoutError", "AbortError", "NetworkingError"].includes(name) || + (statusCode !== undefined && statusCode >= 500) + ); +} + +function stableGatewayPolicyKey(roleName: string, gatewayId: string): string { + return roleName.startsWith("AgentCoreCliGateway-") ? roleName : gatewayId; +} + +function isPendingAuthorizationStatus(status: string | undefined): boolean { + return ( + status === "CREATE_PENDING_AUTH" || + status === "UPDATE_PENDING_AUTH" || + status === "SYNCHRONIZE_PENDING_AUTH" + ); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); } diff --git a/src/core/gatewayIam.test.ts b/src/core/gatewayIam.test.ts new file mode 100644 index 000000000..fc50c6171 --- /dev/null +++ b/src/core/gatewayIam.test.ts @@ -0,0 +1,687 @@ +import { describe, expect, test } from "bun:test"; +import { + CreateGatewayCommand, + GetGatewayCommand, + ListGatewaysCommand, + type BedrockAgentCoreControlClient, + type CreateGatewayResponse, + type GetGatewayResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + GetRoleCommand, + GetRolePolicyCommand, + ListRolePoliciesCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import type { AwsClients } from "./types"; +import { GatewayClient, GatewayTerminalStateError } from "./gateway"; +import { PolicyFinalizationError } from "./executionRolePolicyUpdater"; +import { ExecutionRoleManager } from "./executionRoleManager"; + +const REGION = "us-west-2"; +const ACCOUNT_ID = "123456789012"; +const ROLE_NAME = "AgentCoreCliGateway-orders"; +const ROLE_ARN = `arn:aws:iam::${ACCOUNT_ID}:role/${ROLE_NAME}`; +const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc123"; +const POLICY_ENGINE_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders"; + +type IamCommand = + | CreateRoleCommand + | DeleteRoleCommand + | DeleteRolePolicyCommand + | GetRoleCommand + | GetRolePolicyCommand + | ListRolePoliciesCommand + | PutRolePolicyCommand; + +function policyPermissions(document: string): string[] { + const parsed = JSON.parse(document) as { + Statement: { Action: string[]; Resource: string[] }[]; + }; + return parsed.Statement.flatMap((statement) => + statement.Action.flatMap((action) => + statement.Resource.map((resource) => `${action} ${resource}`), + ), + ).sort(); +} + +describe("GatewayClient managed execution role", () => { + test("creates the role, stages Policy Engine access, waits, and finalizes exact Gateway access", async () => { + const events: string[] = []; + const policies = new Map(); + let roleExists = false; + const iam = { + send: async (command: IamCommand) => { + events.push(command.constructor.name); + if (command instanceof GetRoleCommand) { + if (!roleExists) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { + Role: { + RoleName: ROLE_NAME, + Arn: ROLE_ARN, + AssumeRolePolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }), + }, + }; + } + if (command instanceof CreateRoleCommand) { + roleExists = true; + return { Role: { RoleName: ROLE_NAME, Arn: ROLE_ARN } }; + } + if (command instanceof ListRolePoliciesCommand) { + return { PolicyNames: [...policies.keys()], IsTruncated: false }; + } + if (command instanceof PutRolePolicyCommand) { + policies.set(command.input.PolicyName!, command.input.PolicyDocument!); + return {}; + } + if (command instanceof GetRolePolicyCommand) { + const document = policies.get(command.input.PolicyName!); + if (!document) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { PolicyDocument: document }; + } + if (command instanceof DeleteRolePolicyCommand) { + policies.delete(command.input.PolicyName!); + return {}; + } + if (command instanceof DeleteRoleCommand) { + roleExists = false; + return {}; + } + throw new Error("unexpected IAM command"); + }, + } as unknown as IAMClient; + + const created: CreateGatewayResponse = { + gatewayArn: GATEWAY_ARN, + gatewayId: "orders-abc123", + createdAt: new Date("2026-08-12T00:00:00Z"), + updatedAt: new Date("2026-08-12T00:00:00Z"), + status: "CREATING", + name: "orders", + roleArn: ROLE_ARN, + authorizerType: "NONE", + }; + const ready: GetGatewayResponse = { + ...created, + status: "READY", + policyEngineConfiguration: { + arn: POLICY_ENGINE_ARN, + mode: "ENFORCE", + }, + }; + let createInput: CreateGatewayCommand["input"] | undefined; + const createTokens: string[] = []; + const sleeps: number[] = []; + const control = { + send: async (command: CreateGatewayCommand | GetGatewayCommand) => { + events.push(command.constructor.name); + if (command instanceof CreateGatewayCommand) { + createInput = command.input; + createTokens.push(command.input.clientToken!); + const staged = [...policies.values()][0]; + expect(staged).toBeDefined(); + expect(policyPermissions(staged!)).toEqual([ + `bedrock-agentcore:AuthorizeAction arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:gateway/*`, + `bedrock-agentcore:AuthorizeAction ${POLICY_ENGINE_ARN}`, + `bedrock-agentcore:GetPolicyEngine ${POLICY_ENGINE_ARN}`, + `bedrock-agentcore:PartiallyAuthorizeActions arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:gateway/*`, + `bedrock-agentcore:PartiallyAuthorizeActions ${POLICY_ENGINE_ARN}`, + ]); + if (createTokens.length === 1) { + const error = new Error("execution role cannot be assumed yet"); + error.name = "ValidationException"; + throw error; + } + return created; + } + return ready; + }, + } as unknown as BedrockAgentCoreControlClient; + const clients = { + control: () => control, + iam: () => iam, + } as unknown as AwsClients; + const gateway = new GatewayClient(clients, { + policyUpdater: { + retryDelayMs: 0, + sleep: async (milliseconds) => { + sleeps.push(milliseconds); + }, + }, + waitDelayMs: 0, + }); + + const response = await gateway.createGateway( + { + name: "orders", + roleArn: undefined, + authorizerType: "NONE", + policyEngineConfiguration: { + arn: POLICY_ENGINE_ARN, + mode: "ENFORCE", + }, + }, + { region: REGION }, + ); + + expect(response).toEqual(created); + expect(createInput).toMatchObject({ + name: "orders", + roleArn: ROLE_ARN, + authorizerType: "NONE", + policyEngineConfiguration: { + arn: POLICY_ENGINE_ARN, + mode: "ENFORCE", + }, + clientToken: expect.any(String), + }); + expect(events.indexOf("PutRolePolicyCommand")).toBeLessThan( + events.indexOf("CreateGatewayCommand"), + ); + expect(createTokens).toHaveLength(2); + expect(createTokens[0]).toBe(createTokens[1]); + expect(sleeps).toEqual([10_000, 2_000, 10_000]); + expect(events).toContain("GetGatewayCommand"); + expect(roleExists).toBeTrue(); + expect(policies.size).toBe(1); + const [policyName, finalPolicy] = [...policies.entries()][0]!; + expect(policyName).toMatch(/^AgentCoreCliGatewayExecutionPolicy-[0-9a-f]{16}$/); + expect(policyPermissions(finalPolicy)).toEqual([ + `bedrock-agentcore:AuthorizeAction ${GATEWAY_ARN}`, + `bedrock-agentcore:AuthorizeAction ${POLICY_ENGINE_ARN}`, + `bedrock-agentcore:GetPolicyEngine ${POLICY_ENGINE_ARN}`, + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `bedrock-agentcore:PartiallyAuthorizeActions ${GATEWAY_ARN}`, + `bedrock-agentcore:PartiallyAuthorizeActions ${POLICY_ENGINE_ARN}`, + ]); + }); + + test("removes a newly created role after Gateway reaches FAILED", async () => { + let roleExists = false; + const policies = new Map(); + const iam = { + send: async (command: IamCommand) => { + if (command instanceof GetRoleCommand) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + if (command instanceof CreateRoleCommand) { + roleExists = true; + return { Role: { RoleName: ROLE_NAME, Arn: ROLE_ARN } }; + } + if (command instanceof DeleteRolePolicyCommand) { + policies.delete(command.input.PolicyName!); + return {}; + } + if (command instanceof DeleteRoleCommand) { + roleExists = false; + return {}; + } + if (command instanceof GetRolePolicyCommand) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + if (command instanceof ListRolePoliciesCommand) { + return { PolicyNames: [...policies.keys()], IsTruncated: false }; + } + if (command instanceof PutRolePolicyCommand) { + policies.set(command.input.PolicyName!, command.input.PolicyDocument!); + return {}; + } + throw new Error("unexpected IAM command"); + }, + } as unknown as IAMClient; + const control = { + send: async (command: CreateGatewayCommand | GetGatewayCommand) => + command instanceof CreateGatewayCommand + ? { + gatewayArn: GATEWAY_ARN, + gatewayId: "orders-abc123", + status: "CREATING", + name: "orders", + roleArn: ROLE_ARN, + authorizerType: "NONE", + createdAt: new Date(), + updatedAt: new Date(), + } + : { + gatewayArn: GATEWAY_ARN, + gatewayId: "orders-abc123", + status: "FAILED", + statusReasons: ["service rejected the configuration"], + name: "orders", + roleArn: ROLE_ARN, + authorizerType: "NONE", + createdAt: new Date(), + updatedAt: new Date(), + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => iam, + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + roleManager: { cleanupRetryDelayMs: 0 }, + waitDelayMs: 0, + }, + ); + + await expect( + client.createGateway( + { + name: "orders", + roleArn: undefined, + authorizerType: "NONE", + }, + { region: REGION }, + ), + ).rejects.toBeInstanceOf(GatewayTerminalStateError); + expect(roleExists).toBeFalse(); + expect(policies.size).toBe(0); + }); + + test("retains the role and transition policy when finalization fails after READY", async () => { + let roleExists = false; + let putCount = 0; + const policies = new Map(); + const iam = { + send: async (command: IamCommand) => { + if (command instanceof GetRoleCommand) { + if (!roleExists) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { + Role: { + RoleName: ROLE_NAME, + Arn: ROLE_ARN, + AssumeRolePolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }), + }, + }; + } + if (command instanceof CreateRoleCommand) { + roleExists = true; + return { Role: { RoleName: ROLE_NAME, Arn: ROLE_ARN } }; + } + if (command instanceof ListRolePoliciesCommand) { + return { PolicyNames: [...policies.keys()], IsTruncated: false }; + } + if (command instanceof PutRolePolicyCommand) { + putCount++; + if (putCount === 2) throw new Error("final PutRolePolicy failed"); + policies.set(command.input.PolicyName!, command.input.PolicyDocument!); + return {}; + } + if (command instanceof GetRolePolicyCommand) { + return { PolicyDocument: policies.get(command.input.PolicyName!) }; + } + if (command instanceof DeleteRolePolicyCommand) { + policies.delete(command.input.PolicyName!); + return {}; + } + if (command instanceof DeleteRoleCommand) { + roleExists = false; + return {}; + } + throw new Error("unexpected IAM command"); + }, + } as unknown as IAMClient; + const created = { + gatewayArn: GATEWAY_ARN, + gatewayId: "orders-abc123", + status: "CREATING" as const, + name: "orders", + roleArn: ROLE_ARN, + authorizerType: "NONE" as const, + createdAt: new Date(), + updatedAt: new Date(), + policyEngineConfiguration: { arn: POLICY_ENGINE_ARN, mode: "ENFORCE" as const }, + }; + const control = { + send: async (command: CreateGatewayCommand | GetGatewayCommand) => + command instanceof CreateGatewayCommand ? created : { ...created, status: "READY" }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => iam, + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + roleManager: { cleanupRetryDelayMs: 0 }, + waitDelayMs: 0, + }, + ); + + const error = await client + .createGateway( + { + name: "orders", + roleArn: undefined, + authorizerType: "NONE", + policyEngineConfiguration: { arn: POLICY_ENGINE_ARN, mode: "ENFORCE" }, + }, + { region: REGION }, + ) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(PolicyFinalizationError); + expect((error as Error).message).toContain("gatewayId orders-abc123"); + expect((error as Error).message).toContain("may not be invokable"); + expect((error as Error).message).toContain("Rerun the same command"); + expect(roleExists).toBeTrue(); + expect(policies.size).toBe(1); + expect(policyPermissions([...policies.values()][0]!)).not.toContain( + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + ); + }); + + test("observes an ambiguously successful create instead of deleting its role", async () => { + let roleExists = false; + const policies = new Map(); + const iam = { + send: async (command: IamCommand) => { + if (command instanceof GetRoleCommand) { + if (!roleExists) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { + Role: { + RoleName: ROLE_NAME, + Arn: ROLE_ARN, + AssumeRolePolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }), + }, + }; + } + if (command instanceof CreateRoleCommand) { + roleExists = true; + return { Role: { RoleName: ROLE_NAME, Arn: ROLE_ARN } }; + } + if (command instanceof ListRolePoliciesCommand) { + return { PolicyNames: [...policies.keys()], IsTruncated: false }; + } + if (command instanceof PutRolePolicyCommand) { + policies.set(command.input.PolicyName!, command.input.PolicyDocument!); + return {}; + } + if (command instanceof GetRolePolicyCommand) { + return { PolicyDocument: policies.get(command.input.PolicyName!) }; + } + if (command instanceof DeleteRolePolicyCommand) { + policies.delete(command.input.PolicyName!); + return {}; + } + if (command instanceof DeleteRoleCommand) { + roleExists = false; + return {}; + } + throw new Error("unexpected IAM command"); + }, + } as unknown as IAMClient; + let accepted = false; + const settled: GetGatewayResponse = { + gatewayArn: GATEWAY_ARN, + gatewayId: "orders-abc123", + createdAt: new Date("2026-08-12T00:00:00Z"), + updatedAt: new Date("2026-08-12T00:00:00Z"), + status: "READY", + name: "orders", + roleArn: ROLE_ARN, + authorizerType: "NONE", + }; + const control = { + send: async (command: CreateGatewayCommand | GetGatewayCommand | ListGatewaysCommand) => { + if (command instanceof CreateGatewayCommand) { + accepted = true; + const error = new Error("response connection closed"); + error.name = "TimeoutError"; + throw error; + } + if (command instanceof ListGatewaysCommand) { + return { + items: accepted + ? [ + { + gatewayId: settled.gatewayId, + name: settled.name, + status: "CREATING", + }, + ] + : [], + }; + } + return settled; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => iam, + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + waitDelayMs: 0, + }, + ); + + await expect( + client.createGateway( + { + name: "orders", + roleArn: undefined, + authorizerType: "NONE", + }, + { region: REGION }, + ), + ).resolves.toMatchObject({ + gatewayId: "orders-abc123", + status: "READY", + }); + expect(roleExists).toBeTrue(); + expect(policyPermissions([...policies.values()][0]!)).toContain( + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + ); + }); + + test("preserves an existing managed role policy after confirmed create failure", async () => { + const policyName = ExecutionRoleManager.generatedPolicyName("gateway", { + accountId: ACCOUNT_ID, + region: REGION, + stableResourceKey: ROLE_NAME, + }); + const existingResource = "arn:aws:s3:::existing-gateway/schema.json"; + const policies = new Map([ + [ + policyName, + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["s3:GetObject"], + Resource: [existingResource], + }, + ], + }), + ], + ]); + let roleDeleted = false; + const iam = { + send: async (command: IamCommand) => { + if (command instanceof GetRoleCommand) { + return { + Role: { + RoleName: ROLE_NAME, + Arn: ROLE_ARN, + AssumeRolePolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }), + }, + }; + } + if (command instanceof ListRolePoliciesCommand) { + return { PolicyNames: [...policies.keys()], IsTruncated: false }; + } + if (command instanceof GetRolePolicyCommand) { + return { PolicyDocument: policies.get(command.input.PolicyName!) }; + } + if (command instanceof PutRolePolicyCommand) { + policies.set(command.input.PolicyName!, command.input.PolicyDocument!); + return {}; + } + if (command instanceof DeleteRolePolicyCommand) { + policies.delete(command.input.PolicyName!); + return {}; + } + if (command instanceof DeleteRoleCommand) { + roleDeleted = true; + return {}; + } + throw new Error("unexpected IAM command"); + }, + } as unknown as IAMClient; + const control = { + send: async () => { + const error = new Error("request rejected before create"); + error.name = "ValidationException"; + throw error; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => iam, + } as unknown as AwsClients, + { + policyUpdater: { + propagationDelayMs: 0, + retryDelayMs: 0, + }, + waitDelayMs: 0, + }, + ); + + await expect( + client.createGateway( + { + name: "orders", + roleArn: undefined, + authorizerType: "NONE", + }, + { region: REGION }, + ), + ).rejects.toThrow("request rejected before create"); + expect(roleDeleted).toBeFalse(); + expect(policies.size).toBe(1); + expect(policyPermissions(policies.get(policyName)!)).toContain( + `s3:GetObject ${existingResource}`, + ); + }); +}); + +describe("GatewayClient customer-managed execution role", () => { + test("passes an explicit role through without any IAM calls", async () => { + const response: CreateGatewayResponse = { + gatewayArn: GATEWAY_ARN, + gatewayId: "orders-abc123", + createdAt: new Date("2026-08-12T00:00:00Z"), + updatedAt: new Date("2026-08-12T00:00:00Z"), + status: "CREATING", + name: "orders", + roleArn: "arn:aws:iam::123456789012:role/CustomerGatewayRole", + authorizerType: "AWS_IAM", + }; + let sentInput: CreateGatewayCommand["input"] | undefined; + const control = { + send: async (command: CreateGatewayCommand | GetGatewayCommand) => { + if (command instanceof CreateGatewayCommand) { + sentInput = command.input; + return response; + } + return { ...response, status: "READY" }; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => { + throw new Error("IAM must not be requested"); + }, + } as unknown as AwsClients, + { + waitAttempts: 1, + waitDelayMs: 0, + }, + ); + + await expect( + client.createGateway( + { + name: "orders", + roleArn: response.roleArn, + authorizerType: "AWS_IAM", + }, + { region: REGION }, + ), + ).resolves.toEqual(response); + expect(sentInput).toEqual({ + name: "orders", + clientToken: expect.any(String), + roleArn: response.roleArn, + authorizerType: "AWS_IAM", + }); + }); +}); diff --git a/src/core/gatewayPolicy.test.ts b/src/core/gatewayPolicy.test.ts new file mode 100644 index 000000000..c7b7f59ce --- /dev/null +++ b/src/core/gatewayPolicy.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, test } from "bun:test"; +import { PolicyCompiler } from "./executionRolePolicy"; +import { + GatewayPolicyPlanner, + UninferrableGatewayPermissionError, + type GatewayPolicyState, +} from "./gatewayPolicy"; + +const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc123"; +const POLICY_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 WEB_SEARCH_ARN = "arn:aws:bedrock-agentcore::aws:tool/web-search.v1"; +const REGIONAL_WEB_SEARCH_ARN = "arn:aws:bedrock-agentcore:us-west-2:aws:tool/web-search.v1"; + +describe("GatewayPolicyPlanner", () => { + test("plans exact root, Policy Engine, Lambda, and Web Search permissions", () => { + const contributions = new GatewayPolicyPlanner().plan({ + gatewayArn: GATEWAY_ARN, + policyEngineConfiguration: { + arn: POLICY_ENGINE_ARN, + mode: "ENFORCE", + }, + targets: [ + { + targetId: "lambda-target", + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: LAMBDA_ARN, + toolSchema: { inlinePayload: [] }, + }, + }, + }, + }, + { + targetId: "web-search-target", + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "web-search" }, + }, + }, + }, + }, + ], + }); + const compiled = new PolicyCompiler().compile(contributions); + + expect(compiled.permissions.map(({ action, resource }) => `${action} ${resource}`)).toEqual( + expect.arrayContaining([ + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `bedrock-agentcore:GetPolicyEngine ${POLICY_ENGINE_ARN}`, + `bedrock-agentcore:AuthorizeAction ${POLICY_ENGINE_ARN}`, + `bedrock-agentcore:AuthorizeAction ${GATEWAY_ARN}`, + `bedrock-agentcore:PartiallyAuthorizeActions ${POLICY_ENGINE_ARN}`, + `bedrock-agentcore:PartiallyAuthorizeActions ${GATEWAY_ARN}`, + `lambda:InvokeFunction ${LAMBDA_ARN}`, + `bedrock-agentcore:InvokeWebSearch ${WEB_SEARCH_ARN}`, + `bedrock-agentcore:InvokeWebSearch ${REGIONAL_WEB_SEARCH_ARN}`, + ]), + ); + expect(compiled.permissions).toHaveLength(9); + }); + + test("allows an HTTP passthrough Target with no execution-role auth", () => { + const contributions = new GatewayPolicyPlanner().plan({ + gatewayArn: GATEWAY_ARN, + targets: [ + { + targetId: "public-http", + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://example.com", + protocolType: "CUSTOM", + }, + }, + }, + }, + ], + }); + + expect(new PolicyCompiler().compile(contributions).permissions).toEqual([ + expect.objectContaining({ + action: "bedrock-agentcore:InvokeGateway", + resource: GATEWAY_ARN, + }), + ]); + }); + + test.each([ + [ + "Policy Engine interceptor", + { + gatewayArn: GATEWAY_ARN, + interceptorConfigurations: [ + { + interceptor: { lambda: { arn: LAMBDA_ARN } }, + interceptionPoints: ["REQUEST"], + }, + ], + targets: [], + }, + ], + [ + "Knowledge Base connector", + { + gatewayArn: GATEWAY_ARN, + targets: [ + { + targetId: "kb", + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "bedrock-knowledge-bases" }, + }, + }, + }, + }, + ], + }, + ], + [ + "API Gateway Target", + { + gatewayArn: GATEWAY_ARN, + targets: [ + { + targetId: "api", + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: "api-id", + stage: "prod", + apiGatewayToolConfiguration: { toolFilters: [] }, + }, + }, + }, + }, + ], + }, + ], + [ + "AgentCore Runtime Target", + { + gatewayArn: GATEWAY_ARN, + targets: [ + { + targetId: "runtime", + targetConfiguration: { + http: { + agentcoreRuntime: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/runtime-id", + }, + }, + }, + }, + ], + }, + ], + [ + "S3 schema", + { + gatewayArn: GATEWAY_ARN, + targets: [ + { + targetId: "schema", + targetConfiguration: { + mcp: { + openApiSchema: { + s3: { uri: "s3://bucket/schema.json" }, + }, + }, + }, + }, + ], + }, + ], + [ + "API key auth", + { + gatewayArn: GATEWAY_ARN, + targets: [ + { + targetId: "api-key", + targetConfiguration: { + mcp: { + mcpServer: { + endpoint: "https://example.com/mcp", + }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "API_KEY", + }, + ], + }, + ], + }, + ], + [ + "HTTP S3 schema", + { + gatewayArn: GATEWAY_ARN, + targets: [ + { + targetId: "http-schema", + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://example.com", + protocolType: "CUSTOM", + schema: { + source: { + s3: { uri: "s3://bucket/http-schema.json" }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + [ + "unknown SDK union", + { + gatewayArn: GATEWAY_ARN, + targets: [ + { + targetId: "future", + targetConfiguration: { + $unknown: ["futureTarget", {}], + }, + }, + ], + }, + ], + ] as [string, GatewayPolicyState][])( + "rejects unimplemented permission-bearing %s before mutation", + (_name, state) => { + expect(() => new GatewayPolicyPlanner().plan(state)).toThrow( + UninferrableGatewayPermissionError, + ); + }, + ); +}); diff --git a/src/core/gatewayPolicy.ts b/src/core/gatewayPolicy.ts new file mode 100644 index 000000000..be5e3a8a2 --- /dev/null +++ b/src/core/gatewayPolicy.ts @@ -0,0 +1,237 @@ +import type { + CredentialProviderConfiguration, + CustomTransformConfiguration, + GatewayInterceptorConfiguration, + GatewayPolicyEngineConfiguration, + TargetConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { AgentCorePolicyGrants } from "./agentCorePolicyGrants"; +import type { PolicyContribution } from "./executionRolePolicy"; + +export type GatewayTargetPolicyState = { + targetId?: string; + name?: string; + targetConfiguration?: TargetConfiguration; + credentialProviderConfigurations?: readonly CredentialProviderConfiguration[]; +}; + +export type GatewayPolicyState = { + gatewayArn?: string; + policyEngineConfiguration?: GatewayPolicyEngineConfiguration; + interceptorConfigurations?: readonly GatewayInterceptorConfiguration[]; + customTransformConfiguration?: CustomTransformConfiguration; + targets: readonly GatewayTargetPolicyState[]; +}; + +export class UninferrableGatewayPermissionError extends Error { + constructor( + readonly owner: string, + message: string, + ) { + super(`Cannot infer execution-role permissions for ${owner}: ${message}`); + this.name = "UninferrableGatewayPermissionError"; + } +} + +export class GatewayPolicyPlanner { + plan(state: GatewayPolicyState): PolicyContribution[] { + const contributions: PolicyContribution[] = []; + + if ((state.interceptorConfigurations?.length ?? 0) > 0) { + throw new UninferrableGatewayPermissionError( + "gateway:interceptors", + "interceptor permissions are not implemented in this stack layer", + ); + } + if (state.customTransformConfiguration) { + throw new UninferrableGatewayPermissionError( + "gateway:custom-transform", + "custom transform permissions are not implemented in this stack layer", + ); + } + if (state.gatewayArn) { + contributions.push({ + owner: "gateway:root", + reason: "invoke Gateway", + statements: [AgentCorePolicyGrants.invokeGateway(state.gatewayArn)], + }); + } + if (state.policyEngineConfiguration) { + const arn = state.policyEngineConfiguration.arn; + if (!arn) { + throw new UninferrableGatewayPermissionError( + "gateway:policy-engine", + "Policy Engine ARN is missing", + ); + } + contributions.push({ + owner: "gateway:policy-engine", + reason: "authorize Gateway requests", + statements: [ + AgentCorePolicyGrants.getPolicyEngine(arn), + AgentCorePolicyGrants.authorizeGateway( + arn, + state.gatewayArn ?? gatewayWildcardFromPolicyEngine(arn), + ), + ], + }); + } + + state.targets.forEach((target, index) => { + const owner = `gateway-target:${target.targetId ?? target.name ?? `pending-${index}`}`; + if (!target.targetConfiguration) { + throw new UninferrableGatewayPermissionError(owner, "Target configuration is missing"); + } + if (containsKey(target.targetConfiguration, "$unknown")) { + throw new UninferrableGatewayPermissionError(owner, "Target contains an unknown SDK union"); + } + const lambda = target.targetConfiguration?.mcp?.lambda; + if (lambda) { + if (!lambda.lambdaArn) { + throw new UninferrableGatewayPermissionError(owner, "Lambda ARN is missing"); + } + contributions.push({ + owner, + reason: "invoke Lambda target", + statements: [AgentCorePolicyGrants.invokeLambda(lambda.lambdaArn)], + }); + this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ + "GATEWAY_IAM_ROLE", + ]); + if (lambda.toolSchema && "s3" in lambda.toolSchema) { + throw new UninferrableGatewayPermissionError( + owner, + "S3 Lambda tool schema permissions are not implemented in this stack layer", + ); + } + return; + } + + const connector = + target.targetConfiguration?.mcp?.connector ?? + target.targetConfiguration?.inference?.connector; + if (connector?.source?.connectorId === "web-search") { + if (!state.gatewayArn) { + throw new UninferrableGatewayPermissionError(owner, "Gateway ARN is missing"); + } + contributions.push({ + owner, + reason: "invoke Web Search connector", + statements: [ + AgentCorePolicyGrants.invokeWebSearch(webSearchArnsFromGateway(state.gatewayArn)), + ], + }); + this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ + "GATEWAY_IAM_ROLE", + ]); + return; + } + + const mcpServer = target.targetConfiguration.mcp?.mcpServer; + if (mcpServer) { + this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ + "CALLER_IAM_CREDENTIALS", + "JWT_PASSTHROUGH", + ]); + if (mcpServer.mcpToolSchema && "s3" in mcpServer.mcpToolSchema) { + throw new UninferrableGatewayPermissionError( + owner, + "S3 MCP tool schema permissions are not implemented in this stack layer", + ); + } + return; + } + + const openApiSchema = + target.targetConfiguration.mcp?.openApiSchema ?? + target.targetConfiguration.mcp?.smithyModel; + if (openApiSchema) { + this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ + "CALLER_IAM_CREDENTIALS", + "JWT_PASSTHROUGH", + ]); + if ("s3" in openApiSchema) { + throw new UninferrableGatewayPermissionError( + owner, + "S3 schema permissions are not implemented in this stack layer", + ); + } + return; + } + + if (target.targetConfiguration.http?.passthrough) { + this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ + "CALLER_IAM_CREDENTIALS", + "JWT_PASSTHROUGH", + ]); + if (containsKey(target.targetConfiguration.http.passthrough, "s3")) { + throw new UninferrableGatewayPermissionError( + owner, + "S3 HTTP schema permissions are not implemented in this stack layer", + ); + } + return; + } + + throw new UninferrableGatewayPermissionError( + owner, + "Target type permissions are not implemented in this stack layer", + ); + }); + + return contributions; + } + + private validateCredentialProviders( + owner: string, + configurations: readonly CredentialProviderConfiguration[] | undefined, + allowedTypes: readonly string[], + ): void { + for (const configuration of configurations ?? []) { + if (!allowedTypes.includes(configuration.credentialProviderType ?? "")) { + throw new UninferrableGatewayPermissionError( + owner, + `credential provider ${configuration.credentialProviderType ?? "unknown"} is not implemented in this stack layer`, + ); + } + } + } +} + +function containsKey(value: unknown, key: string): boolean { + if (Array.isArray(value)) return value.some((entry) => containsKey(entry, key)); + if (value === null || typeof value !== "object") return false; + const record = value as Record; + return key in record || Object.values(record).some((entry) => containsKey(entry, key)); +} + +function webSearchArnsFromGateway(gatewayArn: string): string[] { + const [prefix, partition, service, region] = gatewayArn.split(":"); + if (prefix !== "arn" || !partition || service !== "bedrock-agentcore" || !region) { + throw new UninferrableGatewayPermissionError( + "gateway:web-search", + `invalid Gateway ARN "${gatewayArn}"`, + ); + } + return [ + `arn:${partition}:${service}::aws:tool/web-search.v1`, + `arn:${partition}:${service}:${region}:aws:tool/web-search.v1`, + ]; +} +function gatewayWildcardFromPolicyEngine(policyEngineArn: string): string { + const [prefix, partition, service, region, accountId, resource] = policyEngineArn.split(":"); + if ( + prefix !== "arn" || + !partition || + service !== "bedrock-agentcore" || + !region || + !accountId || + !resource?.startsWith("policy-engine/") + ) { + throw new UninferrableGatewayPermissionError( + "gateway:policy-engine", + `invalid Policy Engine ARN "${policyEngineArn}"`, + ); + } + return `arn:${partition}:${service}:${region}:${accountId}:gateway/*`; +} diff --git a/src/core/gatewayTargetIam.test.ts b/src/core/gatewayTargetIam.test.ts new file mode 100644 index 000000000..72d2baa73 --- /dev/null +++ b/src/core/gatewayTargetIam.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, test } from "bun:test"; +import { + CreateGatewayTargetCommand, + GetGatewayCommand, + GetGatewayTargetCommand, + ListGatewayTargetsCommand, + type BedrockAgentCoreControlClient, + type GetGatewayResponse, + type GetGatewayTargetResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + GetRoleCommand, + GetRolePolicyCommand, + ListRolePoliciesCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import { ExecutionRoleManager } from "./executionRoleManager"; +import { PolicyCompiler } from "./executionRolePolicy"; +import { GatewayPolicyPlanner } from "./gatewayPolicy"; +import type { AwsClients } from "./types"; +import { GatewayClient } from "./gateway"; + +const REGION = "us-west-2"; +const ACCOUNT_ID = "123456789012"; +const ROLE_NAME = "AgentCoreCliGateway-orders"; +const ROLE_ARN = `arn:aws:iam::${ACCOUNT_ID}:role/${ROLE_NAME}`; +const GATEWAY_ID = "orders-abc123"; +const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc123"; +const LAMBDA_ARN = "arn:aws:lambda:us-west-2:123456789012:function:orders"; +const WEB_SEARCH_ARN = "arn:aws:bedrock-agentcore::aws:tool/web-search.v1"; +const REGIONAL_WEB_SEARCH_ARN = "arn:aws:bedrock-agentcore:us-west-2:aws:tool/web-search.v1"; +const POLICY_NAME = ExecutionRoleManager.generatedPolicyName("gateway", { + accountId: ACCOUNT_ID, + region: REGION, + stableResourceKey: ROLE_NAME, +}); + +type IamCommand = + GetRoleCommand | GetRolePolicyCommand | ListRolePoliciesCommand | PutRolePolicyCommand; + +function permissions(document: string): string[] { + const parsed = JSON.parse(document) as { + Statement: { Action: string[]; Resource: string[] }[]; + }; + return parsed.Statement.flatMap((statement) => + statement.Action.flatMap((action) => + statement.Resource.map((resource) => `${action} ${resource}`), + ), + ).sort(); +} + +describe("GatewayClient Target IAM reconciliation", () => { + test("stages and finalizes exact Lambda permission on a recognized role", async () => { + const planner = new GatewayPolicyPlanner(); + const policies = new Map([ + [ + POLICY_NAME, + new PolicyCompiler().compile(planner.plan({ gatewayArn: GATEWAY_ARN, targets: [] })).json, + ], + ]); + const iam = { + send: async (command: IamCommand) => { + if (command instanceof GetRoleCommand) { + return { + Role: { + RoleName: ROLE_NAME, + Arn: ROLE_ARN, + AssumeRolePolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }), + }, + }; + } + if (command instanceof ListRolePoliciesCommand) { + return { PolicyNames: [...policies.keys()], IsTruncated: false }; + } + if (command instanceof PutRolePolicyCommand) { + policies.set(command.input.PolicyName!, command.input.PolicyDocument!); + return {}; + } + if (command instanceof GetRolePolicyCommand) { + return { PolicyDocument: policies.get(command.input.PolicyName!) }; + } + throw new Error("unexpected IAM command"); + }, + } as unknown as IAMClient; + + const gateway: GetGatewayResponse = { + gatewayArn: GATEWAY_ARN, + gatewayId: GATEWAY_ID, + createdAt: new Date("2026-08-12T00:00:00Z"), + updatedAt: new Date("2026-08-12T00:00:00Z"), + status: "READY", + name: "orders", + roleArn: ROLE_ARN, + authorizerType: "NONE", + }; + const lambdaTarget: GetGatewayTargetResponse = { + gatewayArn: GATEWAY_ARN, + targetId: "lambda-target", + createdAt: new Date("2026-08-12T00:00:00Z"), + updatedAt: new Date("2026-08-12T00:00:00Z"), + status: "READY", + name: "lambda", + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: LAMBDA_ARN, + toolSchema: { inlinePayload: [] }, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }; + const webSearchTarget: GetGatewayTargetResponse = { + gatewayArn: GATEWAY_ARN, + targetId: "web-search-target", + createdAt: new Date("2026-08-12T00:00:00Z"), + updatedAt: new Date("2026-08-12T00:00:00Z"), + status: "READY", + name: "web-search", + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "web-search" }, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }; + const targets: GetGatewayTargetResponse[] = []; + const listedTokens: (string | undefined)[] = []; + const control = { + send: async ( + command: + | CreateGatewayTargetCommand + | GetGatewayCommand + | GetGatewayTargetCommand + | ListGatewayTargetsCommand, + ) => { + if (command instanceof GetGatewayCommand) return gateway; + if (command instanceof ListGatewayTargetsCommand) { + listedTokens.push(command.input.nextToken); + if (targets.length > 1) { + return command.input.nextToken + ? { + items: [ + { + targetId: targets[1]!.targetId, + name: targets[1]!.name, + status: targets[1]!.status, + }, + ], + } + : { + items: [ + { + targetId: targets[0]!.targetId, + name: targets[0]!.name, + status: targets[0]!.status, + }, + ], + nextToken: "page-2", + }; + } + return { + items: targets.map((target) => ({ + targetId: target.targetId, + name: target.name, + status: target.status, + })), + }; + } + if (command instanceof GetGatewayTargetCommand) { + return targets.find((target) => target.targetId === command.input.targetId); + } + if (command instanceof CreateGatewayTargetCommand) { + const staged = permissions(policies.get(POLICY_NAME)!); + expect(staged).toContain(`bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`); + if (command.input.targetConfiguration?.mcp?.lambda) { + expect(staged).toContain(`lambda:InvokeFunction ${LAMBDA_ARN}`); + targets.push(lambdaTarget); + return { ...lambdaTarget, status: "CREATING" }; + } + expect(staged).toContain(`lambda:InvokeFunction ${LAMBDA_ARN}`); + expect(staged).toContain(`bedrock-agentcore:InvokeWebSearch ${WEB_SEARCH_ARN}`); + expect(staged).toContain(`bedrock-agentcore:InvokeWebSearch ${REGIONAL_WEB_SEARCH_ARN}`); + targets.push(webSearchTarget); + return { ...webSearchTarget, status: "CREATING" }; + } + throw new Error("unexpected control command"); + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => iam, + } as unknown as AwsClients, + { + policyUpdater: { + propagationDelayMs: 0, + retryDelayMs: 0, + }, + waitDelayMs: 0, + }, + ); + + const lambdaResult = await client.createGatewayTarget( + { + gatewayIdentifier: GATEWAY_ID, + name: "lambda", + targetConfiguration: lambdaTarget.targetConfiguration!, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + { region: REGION }, + ); + + expect(lambdaResult).toEqual({ + response: { ...lambdaTarget, status: "CREATING" }, + }); + expect(permissions(policies.get(POLICY_NAME)!)).toEqual([ + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `lambda:InvokeFunction ${LAMBDA_ARN}`, + ]); + + const webSearchResult = await client.createGatewayTarget( + { + gatewayIdentifier: GATEWAY_ID, + name: "web-search", + targetConfiguration: webSearchTarget.targetConfiguration!, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + { region: REGION }, + ); + + expect(webSearchResult).toEqual({ + response: { ...webSearchTarget, status: "CREATING" }, + }); + expect(permissions(policies.get(POLICY_NAME)!)).toEqual([ + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `bedrock-agentcore:InvokeWebSearch ${WEB_SEARCH_ARN}`, + `bedrock-agentcore:InvokeWebSearch ${REGIONAL_WEB_SEARCH_ARN}`, + `lambda:InvokeFunction ${LAMBDA_ARN}`, + ]); + expect(listedTokens).toContain("page-2"); + }); + + test("returns pending OAuth authorization data for an external role", async () => { + const customerRoleArn = `arn:aws:iam::${ACCOUNT_ID}:role/CustomerGatewayRole`; + const gateway: GetGatewayResponse = { + gatewayArn: GATEWAY_ARN, + gatewayId: GATEWAY_ID, + createdAt: new Date(), + updatedAt: new Date(), + status: "READY", + name: "orders", + roleArn: customerRoleArn, + authorizerType: "NONE", + }; + const created = { + gatewayArn: GATEWAY_ARN, + targetId: "oauth-target", + createdAt: new Date(), + updatedAt: new Date(), + status: "CREATING", + name: "oauth", + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.com/mcp" } }, + }, + } as GetGatewayTargetResponse; + const pending = { + ...created, + status: "CREATE_PENDING_AUTH", + authorizationData: { + oauth2: { + authorizationUrl: "https://example.com/authorize", + userId: "user-1", + }, + }, + } as GetGatewayTargetResponse; + const control = { + send: async ( + command: CreateGatewayTargetCommand | GetGatewayCommand | GetGatewayTargetCommand, + ) => { + if (command instanceof GetGatewayCommand) return gateway; + if (command instanceof CreateGatewayTargetCommand) return created; + return pending; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => { + throw new Error("IAM must not be requested"); + }, + } as unknown as AwsClients, + { waitAttempts: 2, waitDelayMs: 0 }, + ); + + const result = await client.createGatewayTarget( + { + gatewayIdentifier: GATEWAY_ID, + name: "oauth", + targetConfiguration: created.targetConfiguration!, + credentialProviderConfigurations: [{ credentialProviderType: "OAUTH" }], + }, + { region: REGION }, + ); + expect(result).toMatchObject({ + response: { + status: "CREATE_PENDING_AUTH", + authorizationData: { + oauth2: { + authorizationUrl: "https://example.com/authorize", + }, + }, + }, + rolePolicyWarning: { + reason: "unknown-role", + roleArn: customerRoleArn, + }, + }); + }); +}); diff --git a/src/core/index.tsx b/src/core/index.tsx index 2ed5ff93f..e24c96ce8 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -3,7 +3,7 @@ import { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; import { IAMClient } from "@aws-sdk/client-iam"; import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; import { EvalClient } from "./eval"; -import { GatewayClient } from "./gateway"; +import { GatewayClient, type GatewayClientOptions } from "./gateway"; import { HarnessClient } from "./harness"; import { IdentityClient } from "./identity"; import { MemoryClient } from "./memory"; @@ -38,6 +38,7 @@ type CoreClientConfig = { createLogsClient: CreateLogsClient; logger: Logger; fetch?: CoreFetch; + gatewayOptions?: GatewayClientOptions; }; // CoreClient is the single entry point to the Bedrock AgentCore APIs. It owns the @@ -61,7 +62,7 @@ export class CoreClient implements AwsClients { readonly identity: IdentityClient = new IdentityClient(this); readonly memory: MemoryClient = new MemoryClient(this); readonly runtime: RuntimeClient; - readonly gateway: GatewayClient = new GatewayClient(this); + readonly gateway: GatewayClient; readonly eval: EvalClient; readonly projectManager: ProjectManager; @@ -72,6 +73,7 @@ export class CoreClient implements AwsClients { this.createIamClient = config.createIamClient; this.createLogsClient = config.createLogsClient; this.logger = config.logger; + this.gateway = new GatewayClient(this, config.gatewayOptions); const fetch = config.fetch ?? globalThis.fetch; this.runtime = new RuntimeClient(this, fetch, this.logger.child({ module: "runtime" })); // EvalClient shares the injected fetch: dataset content is served from a diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.3c6f4fdc422cd75c.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.3c6f4fdc422cd75c.json new file mode 100644 index 000000000..c70b70080 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.3c6f4fdc422cd75c.json @@ -0,0 +1,17 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "gatewayId": "agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "createdAt": { + "$date": "2026-08-12T22:09:35.171Z" + }, + "updatedAt": { + "$date": "2026-08-12T22:09:35.171Z" + }, + "status": "CREATING", + "name": "agentcore-cli-gw-pr2final737dv2", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gw-pr2final737dv2-9fsgqbtcou.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Create fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gw-pr2final737dv2", + "workloadIdentityDetails": {} +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json deleted file mode 100644 index f64f9cb8f..000000000 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayCommand.7f678b79455bc400.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", - "gatewayId": "agentcore-cli-gateway-create-fixture-bkf6yayfce", - "createdAt": { - "$date": "2026-08-06T17:25:41.425Z" - }, - "updatedAt": { - "$date": "2026-08-06T17:25:41.425Z" - }, - "status": "CREATING", - "name": "agentcore-cli-gateway-create-fixture", - "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-bkf6yayfce.gateway.bedrock-agentcore.us-east-1.amazonaws.com", - "description": "Disposable Gateway Create fixture", - "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", - "workloadIdentityDetails": {} -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d87304453bfe9534.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.5dbeb94f25f7c964.json similarity index 55% rename from src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d87304453bfe9534.json rename to src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.5dbeb94f25f7c964.json index 2b0ab7ee5..e087b10bc 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.d87304453bfe9534.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.5dbeb94f25f7c964.json @@ -1,6 +1,6 @@ { - "ruleId": "93d59ee8-8a9b-4535-9f11-df7006241fed", - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "ruleId": "4acca581-009b-45ff-ad03-a8aed0c53b33", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", "priority": 10, "actions": [ { @@ -12,7 +12,7 @@ } ], "createdAt": { - "$date": "2026-08-06T17:25:57.672Z" + "$date": "2026-08-12T22:10:14.019Z" }, "status": "CREATING", "description": "Disposable Gateway Rule Create fixture" diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.22512d9b9027f76a.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.959bf5ffe0c36d0b.json similarity index 71% rename from src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.22512d9b9027f76a.json rename to src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.959bf5ffe0c36d0b.json index f11639c22..f0a235839 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.22512d9b9027f76a.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.959bf5ffe0c36d0b.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", - "targetId": "U4ADV7VNSV", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "targetId": "SAANHUVEQA", "createdAt": { - "$date": "2026-08-06T17:25:52.273Z" + "$date": "2026-08-12T22:10:11.223Z" }, "updatedAt": { - "$date": "2026-08-06T17:25:52.273Z" + "$date": "2026-08-12T22:10:11.223Z" }, "status": "CREATING", "name": "web-search-fixture", diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.93c8d0503a636294.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.c36b2aed80b6d481.json similarity index 52% rename from src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.93c8d0503a636294.json rename to src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.c36b2aed80b6d481.json index 35aa79c1b..0e29cc795 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.93c8d0503a636294.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.c36b2aed80b6d481.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", - "targetId": "VZ7M0WBGJF", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "targetId": "PTTEODWQQM", "createdAt": { - "$date": "2026-08-06T17:25:46.743Z" + "$date": "2026-08-12T22:09:58.061Z" }, "updatedAt": { - "$date": "2026-08-06T17:25:46.743Z" + "$date": "2026-08-12T22:09:58.061Z" }, "status": "CREATING", "name": "http-fixture", diff --git a/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.b3e0d9996eba4da7.json b/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.b3e0d9996eba4da7.json new file mode 100644 index 000000000..f50c7534b --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/CreateRoleCommand.b3e0d9996eba4da7.json @@ -0,0 +1,12 @@ +{ + "Role": { + "Path": "/", + "RoleName": "AgentCoreCliGateway-agentcore-cli-gw-pr2final737dv2", + "RoleId": "AROAYY3QB54NW2K4IKHFY", + "Arn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gw-pr2final737dv2", + "CreateDate": { + "$date": "2026-08-12T22:09:35.000Z" + }, + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22bedrock-agentcore.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5ff84cafc95022b4.json b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5ff84cafc95022b4.json deleted file mode 100644 index 58e25d0d8..000000000 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.5ff84cafc95022b4.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", - "gatewayId": "agentcore-cli-gateway-create-fixture-bkf6yayfce", - "createdAt": { - "$date": "2026-08-06T17:25:41.425Z" - }, - "updatedAt": { - "$date": "2026-08-06T17:25:42.396Z" - }, - "status": "READY", - "name": "agentcore-cli-gateway-create-fixture", - "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-bkf6yayfce.gateway.bedrock-agentcore.us-east-1.amazonaws.com", - "description": "Disposable Gateway Create fixture", - "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", - "workloadIdentityDetails": { - "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-create-fixture-bkf6yayfce" - } -} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.7209e4c2b10ad15a.json b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.7209e4c2b10ad15a.json new file mode 100644 index 000000000..07d3b8aba --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayCommand.7209e4c2b10ad15a.json @@ -0,0 +1,19 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "gatewayId": "agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "createdAt": { + "$date": "2026-08-12T22:09:35.171Z" + }, + "updatedAt": { + "$date": "2026-08-12T22:09:35.821Z" + }, + "status": "READY", + "name": "agentcore-cli-gw-pr2final737dv2", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gw-pr2final737dv2-9fsgqbtcou.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Create fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gw-pr2final737dv2", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.9a6bd6a64eebb5df.json b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.136772a9fb296e8a.json similarity index 52% rename from src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.9a6bd6a64eebb5df.json rename to src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.136772a9fb296e8a.json index fd163aa51..163793568 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.9a6bd6a64eebb5df.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.136772a9fb296e8a.json @@ -1,6 +1,6 @@ { - "ruleId": "93d59ee8-8a9b-4535-9f11-df7006241fed", - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "ruleId": "4acca581-009b-45ff-ad03-a8aed0c53b33", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", "priority": 10, "actions": [ { @@ -12,11 +12,11 @@ } ], "createdAt": { - "$date": "2026-08-06T17:25:57.672Z" + "$date": "2026-08-12T22:10:14.019Z" }, "status": "ACTIVE", "description": "Disposable Gateway Rule Create fixture", "updatedAt": { - "$date": "2026-08-06T17:25:57.672Z" + "$date": "2026-08-12T22:10:14.019Z" } } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.91cea07923c15ff.json b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.890798045a9f2d8c.json similarity index 52% rename from src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.91cea07923c15ff.json rename to src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.890798045a9f2d8c.json index 4bb1476e1..16956513d 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.91cea07923c15ff.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.890798045a9f2d8c.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", - "targetId": "VZ7M0WBGJF", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "targetId": "PTTEODWQQM", "createdAt": { - "$date": "2026-08-06T17:25:46.743Z" + "$date": "2026-08-12T22:09:58.061Z" }, "updatedAt": { - "$date": "2026-08-06T17:25:47.390Z" + "$date": "2026-08-12T22:09:58.733Z" }, "status": "READY", "name": "http-fixture", diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3855b58e8bc470ab.json b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.d38d917375a78a69.json similarity index 65% rename from src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3855b58e8bc470ab.json rename to src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.d38d917375a78a69.json index 41f19859a..77dd14a59 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.3855b58e8bc470ab.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.d38d917375a78a69.json @@ -1,11 +1,11 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", - "targetId": "U4ADV7VNSV", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "targetId": "SAANHUVEQA", "createdAt": { - "$date": "2026-08-06T17:25:52.273Z" + "$date": "2026-08-12T22:10:11.223Z" }, "updatedAt": { - "$date": "2026-08-06T17:25:53.039Z" + "$date": "2026-08-12T22:10:12.270Z" }, "status": "READY", "name": "web-search-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__/create/GetRoleCommand.fe39e3a34c68009d.json b/src/handlers/gateway/__fixtures__/create/GetRoleCommand.fe39e3a34c68009d.json new file mode 100644 index 000000000..611d2db83 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/GetRoleCommand.fe39e3a34c68009d.json @@ -0,0 +1,15 @@ +{ + "Role": { + "Path": "/", + "RoleName": "AgentCoreCliGateway-agentcore-cli-gw-pr2final737dv2", + "RoleId": "AROAYY3QB54NW2K4IKHFY", + "Arn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gw-pr2final737dv2", + "CreateDate": { + "$date": "2026-08-12T22:09:35.000Z" + }, + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22bedrock-agentcore.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", + "Description": "AgentCore CLI managed execution role for gateway \"agentcore-cli-gw-pr2final737dv2\"", + "MaxSessionDuration": 3600, + "RoleLastUsed": {} + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/GetRolePolicyCommand.2b8ca68519eecef2.json b/src/handlers/gateway/__fixtures__/create/GetRolePolicyCommand.2b8ca68519eecef2.json new file mode 100644 index 000000000..06f668a27 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/GetRolePolicyCommand.2b8ca68519eecef2.json @@ -0,0 +1,5 @@ +{ + "RoleName": "AgentCoreCliGateway-agentcore-cli-gw-pr2final737dv2", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-cf8f357bc44cf708", + "PolicyDocument": "%7B%22Statement%22%3A%5B%7B%22Action%22%3A%5B%22bedrock-agentcore%3AInvokeGateway%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A603141041947%3Agateway%2Fagentcore-cli-gw-pr2final737dv2-9fsgqbtcou%22%5D%7D%2C%7B%22Action%22%3A%5B%22bedrock-agentcore%3AInvokeWebSearch%22%5D%2C%22Effect%22%3A%22Allow%22%2C%22Resource%22%3A%5B%22arn%3Aaws%3Abedrock-agentcore%3A%3Aaws%3Atool%2Fweb-search.v1%22%2C%22arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3Aaws%3Atool%2Fweb-search.v1%22%5D%7D%5D%2C%22Version%22%3A%222012-10-17%22%7D" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/ListGatewayTargetsCommand.cd8ce53f82e9eba8.json b/src/handlers/gateway/__fixtures__/create/ListGatewayTargetsCommand.cd8ce53f82e9eba8.json new file mode 100644 index 000000000..63dfb3221 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/ListGatewayTargetsCommand.cd8ce53f82e9eba8.json @@ -0,0 +1,29 @@ +{ + "items": [ + { + "targetId": "PTTEODWQQM", + "name": "http-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T22:09:58.061Z" + }, + "updatedAt": { + "$date": "2026-08-12T22:09:58.733Z" + }, + "targetType": "PASSTHROUGH" + }, + { + "targetId": "SAANHUVEQA", + "name": "web-search-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T22:10:11.223Z" + }, + "updatedAt": { + "$date": "2026-08-12T22:10:12.270Z" + }, + "description": "Disposable Gateway Connector Create fixture", + "targetType": "CONNECTOR" + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/ListGatewaysCommand.a5e0cc47f0a60d0d.json b/src/handlers/gateway/__fixtures__/create/ListGatewaysCommand.a5e0cc47f0a60d0d.json new file mode 100644 index 000000000..2feb21015 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/ListGatewaysCommand.a5e0cc47f0a60d0d.json @@ -0,0 +1,3 @@ +{ + "items": [] +} diff --git a/src/handlers/gateway/__fixtures__/create/ListRolePoliciesCommand.fe39e3a34c68009d.json b/src/handlers/gateway/__fixtures__/create/ListRolePoliciesCommand.fe39e3a34c68009d.json new file mode 100644 index 000000000..bc7f223b4 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/ListRolePoliciesCommand.fe39e3a34c68009d.json @@ -0,0 +1,6 @@ +{ + "PolicyNames": [ + "AgentCoreCliGatewayExecutionPolicy-cf8f357bc44cf708" + ], + "IsTruncated": false +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/PutRolePolicyCommand.35af5f2a27f4f0d1.json b/src/handlers/gateway/__fixtures__/create/PutRolePolicyCommand.35af5f2a27f4f0d1.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/PutRolePolicyCommand.35af5f2a27f4f0d1.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/PutRolePolicyCommand.c908ecb76c92c2d6.json b/src/handlers/gateway/__fixtures__/create/PutRolePolicyCommand.c908ecb76c92c2d6.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/PutRolePolicyCommand.c908ecb76c92c2d6.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/connector-create.golden.json b/src/handlers/gateway/__fixtures__/create/connector-create.golden.json index 86bb9c031..95a53185d 100644 --- a/src/handlers/gateway/__fixtures__/create/connector-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/connector-create.golden.json @@ -1,8 +1,8 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", - "targetId": "U4ADV7VNSV", - "createdAt": "2026-08-06T17:25:52.273Z", - "updatedAt": "2026-08-06T17:25:52.273Z", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "targetId": "SAANHUVEQA", + "createdAt": "2026-08-12T22:10:11.223Z", + "updatedAt": "2026-08-12T22:10:11.223Z", "status": "CREATING", "name": "web-search-fixture", "targetConfiguration": { diff --git a/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json index 4ec1da7bb..27cda7236 100644 --- a/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json @@ -1,13 +1,13 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", - "gatewayId": "agentcore-cli-gateway-create-fixture-bkf6yayfce", - "createdAt": "2026-08-06T17:25:41.425Z", - "updatedAt": "2026-08-06T17:25:41.425Z", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "gatewayId": "agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "createdAt": "2026-08-12T22:09:35.171Z", + "updatedAt": "2026-08-12T22:09:35.171Z", "status": "CREATING", - "name": "agentcore-cli-gateway-create-fixture", + "name": "agentcore-cli-gw-pr2final737dv2", "authorizerType": "NONE", - "gatewayUrl": "https://agentcore-cli-gateway-create-fixture-bkf6yayfce.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "gatewayUrl": "https://agentcore-cli-gw-pr2final737dv2-9fsgqbtcou.gateway.bedrock-agentcore.us-east-1.amazonaws.com", "description": "Disposable Gateway Create fixture", - "roleArn": "arn:aws:iam::685197708687:role/AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gw-pr2final737dv2", "workloadIdentityDetails": {} } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/resources.json b/src/handlers/gateway/__fixtures__/create/resources.json new file mode 100644 index 000000000..e8df794ed --- /dev/null +++ b/src/handlers/gateway/__fixtures__/create/resources.json @@ -0,0 +1,3 @@ +{ + "gatewayName": "agentcore-cli-gw-pr2final737dv2" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/rule-create.golden.json b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json index b011a33b3..699de41b3 100644 --- a/src/handlers/gateway/__fixtures__/create/rule-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json @@ -1,6 +1,6 @@ { - "ruleId": "93d59ee8-8a9b-4535-9f11-df7006241fed", - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", + "ruleId": "4acca581-009b-45ff-ad03-a8aed0c53b33", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", "priority": 10, "actions": [ { @@ -11,7 +11,7 @@ } } ], - "createdAt": "2026-08-06T17:25:57.672Z", + "createdAt": "2026-08-12T22:10:14.019Z", "status": "CREATING", "description": "Disposable Gateway Rule Create fixture" } \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/create/target-create.golden.json b/src/handlers/gateway/__fixtures__/create/target-create.golden.json index 183dfcdac..92389e3bf 100644 --- a/src/handlers/gateway/__fixtures__/create/target-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/target-create.golden.json @@ -1,8 +1,8 @@ { - "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-create-fixture-bkf6yayfce", - "targetId": "VZ7M0WBGJF", - "createdAt": "2026-08-06T17:25:46.743Z", - "updatedAt": "2026-08-06T17:25:46.743Z", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gw-pr2final737dv2-9fsgqbtcou", + "targetId": "PTTEODWQQM", + "createdAt": "2026-08-12T22:09:58.061Z", + "updatedAt": "2026-08-12T22:09:58.061Z", "status": "CREATING", "name": "http-fixture", "targetConfiguration": { diff --git a/src/handlers/gateway/connector/create/index.tsx b/src/handlers/gateway/connector/create/index.tsx index 329479a1b..f18ff44f3 100644 --- a/src/handlers/gateway/connector/create/index.tsx +++ b/src/handlers/gateway/connector/create/index.tsx @@ -6,7 +6,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"; @@ -135,8 +135,26 @@ export const createCreateGatewayConnectorHandler = (core: Core, io: AppIO) => ...(privateEndpoint ? { privateEndpoint } : {}), }; - ctx - .require(JsonRendererKey) - .renderJson(await core.gateway.createGatewayTarget(input, coreOptsFromCtx(ctx))); + const options = coreOptsFromCtx(ctx); + const preflightWarning = await core.gateway.getGatewayRolePolicyWarning( + input.gatewayIdentifier!, + options, + ); + if (preflightWarning) { + warn( + io, + `Execution role ${preflightWarning.roleArn} is not recognized as AgentCore CLI or console managed. ` + + "The CLI did not modify its IAM policies; you are responsible for this Target's permissions.", + ); + } + const result = await core.gateway.createGatewayTarget(input, options); + if (result.rolePolicyWarning && !preflightWarning) { + warn( + io, + `Execution role ${result.rolePolicyWarning.roleArn} is not recognized as AgentCore CLI or console managed. ` + + "The CLI did not modify its IAM policies; you are responsible for this Target's permissions.", + ); + } + ctx.require(JsonRendererKey).renderJson(result.response); }, }); diff --git a/src/handlers/gateway/create/index.tsx b/src/handlers/gateway/create/index.tsx index be0f56fa1..6e2ff624e 100644 --- a/src/handlers/gateway/create/index.tsx +++ b/src/handlers/gateway/create/index.tsx @@ -18,7 +18,11 @@ export const createCreateGatewayHandler = (core: Core, io: AppIO) => description: "create an AgentCore Gateway", flags: [ flag("name", "the Gateway name", z.string().optional()), - flag("role-arn", "IAM role the Gateway assumes", z.string().optional()), + flag( + "role-arn", + "customer-managed IAM role; a CLI-managed role is created when omitted", + z.string().optional(), + ), flag( "protocol", "restrict Target protocols to MCP; omitted allows every Target protocol", @@ -64,9 +68,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", @@ -134,9 +135,13 @@ export const createCreateGatewayHandler = (core: Core, io: AppIO) => ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), }; - ctx - .require(JsonRendererKey) - .renderJson(await core.gateway.createGateway(input, coreOptsFromCtx(ctx))); + if (flags["role-arn"]) { + io.stderr.write( + `Using customer-managed execution role ${flags["role-arn"]}; IAM policies will not be modified.\n`, + ); + } + const response = await core.gateway.createGateway(input, coreOptsFromCtx(ctx)); + ctx.require(JsonRendererKey).renderJson(response); }, }); diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index 9d920b925..c312332c5 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { DeleteGatewayCommand, @@ -7,12 +7,12 @@ import { DeleteGatewayTargetCommand, GetGatewayCommand, GetGatewayTargetCommand, + ListGatewaysCommand, } from "@aws-sdk/client-bedrock-agentcore-control"; import { - CreateRoleCommand, DeleteRoleCommand, DeleteRolePolicyCommand, - PutRolePolicyCommand, + ListRolePoliciesCommand, } from "@aws-sdk/client-iam"; import { CoreClient } from "../../core"; import { createControlClient, createIamClient } from "../../core/factories"; @@ -27,20 +27,28 @@ import { import { createRootHandler } from "../index"; const REGION = "us-east-1"; -const GATEWAY_NAME = "agentcore-cli-gateway-create-fixture"; +const FIXTURES = join(import.meta.dir, "__fixtures__", "create"); +const RESOURCE_FIXTURE = join(FIXTURES, "resources.json"); +const recordedResources = isRecording() + ? undefined + : (JSON.parse(readFileSync(RESOURCE_FIXTURE, "utf8")) as { + gatewayName: string; + }); +const GATEWAY_NAME = isRecording() + ? `agentcore-cli-gw-${process.env.AGENTCORE_E2E_RUN_ID ?? Date.now().toString(36)}` + : recordedResources!.gatewayName; const HTTP_TARGET_NAME = "http-fixture"; const CONNECTOR_TARGET_NAME = "web-search-fixture"; -const ROLE_NAME = "AgentCoreGateway-agentcore-cli-gateway-create-fix-f0aa3f799a4b"; -const WEB_SEARCH_POLICY_NAME = "AgentCoreCliWebSearchFixture"; -const FIXTURES = join(import.meta.dir, "__fixtures__", "create"); +const ROLE_NAME = `AgentCoreCliGateway-${GATEWAY_NAME}`; const FLOW_TIMEOUT = 600_000; const TEST_ROLE_ARN = "arn:aws:iam::123456789012:role/GatewayFixtureRole"; -// Record with AWS_PROFILE=e2e-test RECORD=1 bun test src/handlers/gateway/gateway.create.test.tsx +// Record with AWS_PROFILE=deploy RECORD=1 bun test src/handlers/gateway/gateway.create.test.tsx type FixtureState = { gatewayId?: string; targetIds: string[]; ruleId?: string; + roleCreated?: boolean; }; function createFixtureCore(): CoreClient { @@ -52,12 +60,21 @@ function createFixtureCore(): CoreClient { createIamClient, createLogsClient, logger: createSilentLogger(), + gatewayOptions: isRecording() + ? undefined + : { + policyUpdater: { + propagationDelayMs: 0, + retryDelayMs: 0, + }, + waitDelayMs: 0, + }, }); } -async function run(args: string[]): Promise { +async function run(args: string[], core = createFixtureCore()): Promise { const io = testIO(); - const root = createRootHandler(createFixtureCore(), { + const root = createRootHandler(core, { io: io.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), @@ -69,9 +86,10 @@ async function run(args: string[]): Promise { async function pollUntil( args: string[], done: (response: Record) => boolean, + core = createFixtureCore(), ): Promise { for (let attempt = 0; attempt < 60; attempt += 1) { - const response = JSON.parse(await run(args)) as Record; + const response = JSON.parse(await run(args, core)) as Record; if (done(response)) return; if (!isRecording()) { throw new Error(`Replayed fixture for \`${args.join(" ")}\` is not settled`); @@ -104,38 +122,6 @@ async function waitUntilMissing(operation: () => Promise): Promise { - if (!isRecording()) { - return JSON.parse(readFileSync(join(FIXTURES, "gateway-create.golden.json"), "utf8")).roleArn; - } - - const iam = createIamClient({ region: REGION }); - await ignoreMissing(() => - iam.send( - new DeleteRolePolicyCommand({ RoleName: ROLE_NAME, PolicyName: WEB_SEARCH_POLICY_NAME }), - ), - ); - await ignoreMissing(() => iam.send(new DeleteRoleCommand({ RoleName: ROLE_NAME }))); - const response = await iam.send( - new CreateRoleCommand({ - RoleName: ROLE_NAME, - 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 did not return the fixture role ARN"); - await Bun.sleep(10_000); - return response.Role.Arn; -} - async function deleteTarget( control: ReturnType, gatewayId: string, @@ -176,6 +162,18 @@ async function cleanup(state: FixtureState): Promise { if (!isRecording()) return; const control = createControlClient({ region: REGION }); + if (!state.gatewayId && state.roleCreated) { + let nextToken: string | undefined; + do { + const page = await control.send(new ListGatewaysCommand({ nextToken, maxResults: 100 })); + const match = (page.items ?? []).find((gateway) => gateway.name === GATEWAY_NAME); + if (match?.gatewayId) { + state.gatewayId = match.gatewayId; + break; + } + nextToken = page.nextToken; + } while (nextToken); + } if (state.gatewayId && state.ruleId) { await ignoreMissing(() => control.send( @@ -200,32 +198,33 @@ async function cleanup(state: FixtureState): Promise { ); } - const iam = createIamClient({ region: REGION }); - await ignoreMissing(() => - iam.send( - new DeleteRolePolicyCommand({ - RoleName: ROLE_NAME, - PolicyName: WEB_SEARCH_POLICY_NAME, - }), - ), - ); - await ignoreMissing(() => - iam.send( - new DeleteRoleCommand({ - RoleName: ROLE_NAME, - }), - ), - ); + if (state.roleCreated) { + const iam = createIamClient({ region: REGION }); + try { + const policies = await iam.send(new ListRolePoliciesCommand({ RoleName: ROLE_NAME })); + for (const policyName of (policies.PolicyNames ?? []).filter((name) => + name.startsWith("AgentCoreCliGatewayExecutionPolicy-"), + )) { + await ignoreMissing(() => + iam.send(new DeleteRolePolicyCommand({ RoleName: ROLE_NAME, PolicyName: policyName })), + ); + } + } catch (error) { + if ((error as Error).name !== "NoSuchEntityException") throw error; + } + await ignoreMissing(() => + iam.send( + new DeleteRoleCommand({ + RoleName: ROLE_NAME, + }), + ), + ); + } } 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], @@ -473,21 +472,26 @@ describe("Gateway fixture-backed creates", () => { "creates a Gateway, Target, Connector, and Rule through the real Core", async () => { const state: FixtureState = { targetIds: [] }; - const roleArn = await fixtureRoleArn(); + const core = createFixtureCore(); + if (isRecording()) { + state.roleCreated = true; + writeFileSync(RESOURCE_FIXTURE, JSON.stringify({ gatewayName: GATEWAY_NAME }, null, 2)); + } try { - const gatewayStdout = await run([ - "gateway", - "create", - "--name", - GATEWAY_NAME, - "--role-arn", - roleArn, - "--authorizer-type", - "NONE", - "--description", - "Disposable Gateway Create fixture", - ]); + const gatewayStdout = await run( + [ + "gateway", + "create", + "--name", + GATEWAY_NAME, + "--authorizer-type", + "NONE", + "--description", + "Disposable Gateway Create fixture", + ], + core, + ); matchGolden(FIXTURES, "gateway-create.golden.json", gatewayStdout); const gateway = JSON.parse(gatewayStdout); expect(gateway.name).toBe(GATEWAY_NAME); @@ -498,26 +502,30 @@ describe("Gateway fixture-backed creates", () => { await pollUntil( ["gateway", "get", "--id", state.gatewayId!], (response) => response.status === "READY", + core, ); - const targetStdout = await run([ - "gateway", - "target", - "create", - "--gateway-id", - state.gatewayId!, - "--name", - HTTP_TARGET_NAME, - "--target-configuration", - JSON.stringify({ - http: { - passthrough: { - endpoint: "https://example.com", - protocolType: "CUSTOM", + const targetStdout = await run( + [ + "gateway", + "target", + "create", + "--gateway-id", + state.gatewayId!, + "--name", + HTTP_TARGET_NAME, + "--target-configuration", + JSON.stringify({ + http: { + passthrough: { + endpoint: "https://example.com", + protocolType: "CUSTOM", + }, }, - }, - }), - ]); + }), + ], + core, + ); matchGolden(FIXTURES, "target-create.golden.json", targetStdout); const target = JSON.parse(targetStdout); expect(target.targetId).toBeString(); @@ -534,32 +542,25 @@ describe("Gateway fixture-backed creates", () => { target.targetId, ], (response) => response.status === "READY", + core, ); - // TODO: Remove this fixture-only grant when managed Gateway roles reconcile Target permissions. - if (isRecording()) { - await createIamClient({ region: REGION }).send( - new PutRolePolicyCommand({ - RoleName: ROLE_NAME, - PolicyName: WEB_SEARCH_POLICY_NAME, - PolicyDocument: JSON.stringify(webSearchPolicy(gateway.gatewayArn)), - }), - ); - } - - const connectorStdout = await run([ - "gateway", - "connector", - "create", - "--gateway-id", - state.gatewayId!, - "--name", - CONNECTOR_TARGET_NAME, - "--connector", - "web-search", - "--description", - "Disposable Gateway Connector Create fixture", - ]); + const connectorStdout = await run( + [ + "gateway", + "connector", + "create", + "--gateway-id", + state.gatewayId!, + "--name", + CONNECTOR_TARGET_NAME, + "--connector", + "web-search", + "--description", + "Disposable Gateway Connector Create fixture", + ], + core, + ); matchGolden(FIXTURES, "connector-create.golden.json", connectorStdout); const connector = JSON.parse(connectorStdout); expect(connector.targetId).toBeString(); @@ -589,29 +590,33 @@ describe("Gateway fixture-backed creates", () => { connector.targetId, ], (response) => response.status === "READY", + core, ); - const ruleStdout = await run([ - "gateway", - "rule", - "create", - "--gateway-id", - state.gatewayId!, - "--priority", - "10", - "--actions", - JSON.stringify([ - { - routeToTarget: { - staticRoute: { - targetName: HTTP_TARGET_NAME, + const ruleStdout = await run( + [ + "gateway", + "rule", + "create", + "--gateway-id", + state.gatewayId!, + "--priority", + "10", + "--actions", + JSON.stringify([ + { + routeToTarget: { + staticRoute: { + targetName: HTTP_TARGET_NAME, + }, }, }, - }, - ]), - "--description", - "Disposable Gateway Rule Create fixture", - ]); + ]), + "--description", + "Disposable Gateway Rule Create fixture", + ], + core, + ); matchGolden(FIXTURES, "rule-create.golden.json", ruleStdout); const rule = JSON.parse(ruleStdout); expect(rule.ruleId).toBeString(); @@ -620,6 +625,7 @@ describe("Gateway fixture-backed creates", () => { await pollUntil( ["gateway", "rule", "get", "--gateway-id", state.gatewayId!, "--rule-id", state.ruleId!], (response) => response.status === "ACTIVE", + core, ); } finally { await cleanup(state); @@ -628,25 +634,3 @@ describe("Gateway fixture-backed creates", () => { FLOW_TIMEOUT, ); }); - -function webSearchPolicy(gatewayArn: string): Record { - const [prefix, partition, service, region] = gatewayArn.split(":"); - if (prefix !== "arn" || service !== "bedrock-agentcore" || !region) { - throw new Error(`Unexpected Gateway ARN: ${gatewayArn}`); - } - return { - Version: "2012-10-17", - Statement: [ - { - Effect: "Allow", - Action: "bedrock-agentcore:InvokeGateway", - Resource: gatewayArn, - }, - { - Effect: "Allow", - Action: "bedrock-agentcore:InvokeWebSearch", - Resource: `arn:${partition}:${service}:${region}:aws:tool/web-search.v1`, - }, - ], - }; -} diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index e025c526a..00b73bd90 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -16,7 +16,7 @@ const RULE_ID = "rule-1"; async function run( args: string[], core = new TestCoreClient(), -): Promise<{ core: TestCoreClient; stdout: string }> { +): Promise<{ core: TestCoreClient; stdout: string; stderr: string }> { const io = testIO(); const root = createRootHandler(core, { io: io.io, @@ -25,7 +25,7 @@ async function run( }); await root.route(["node", "agentcore", ...args, "--region", REGION]); - return { core, stdout: io.stdout() }; + return { core, stdout: io.stdout(), stderr: io.stderr() }; } function supportsTui(path: readonly string[]): boolean { @@ -131,3 +131,79 @@ describe("gateway validation", () => { expect(core.gateway.calls).toEqual([]); }); }); + +describe("gateway create", () => { + test("allows Core to provision the execution role when --role-arn is omitted", async () => { + const { core } = await run([ + "gateway", + "create", + "--name", + "orders", + "--authorizer-type", + "NONE", + ]); + + expect(core.gateway.calls).toEqual([ + { + method: "createGateway", + args: [ + { + name: "orders", + roleArn: undefined, + authorizerType: "NONE", + }, + { + region: REGION, + endpointUrl: undefined, + }, + ], + }, + ]); + }); + + test("identifies an explicit role as customer-managed", async () => { + const roleArn = "arn:aws:iam::123456789012:role/CustomerGatewayRole"; + const { stderr } = await run([ + "gateway", + "create", + "--name", + "orders", + "--role-arn", + roleArn, + "--authorizer-type", + "NONE", + ]); + + expect(stderr).toContain( + `Using customer-managed execution role ${roleArn}; IAM policies will not be modified.`, + ); + }); + + test("warns without changing JSON output when a Target role is unknown", async () => { + const core = new TestCoreClient(); + const roleArn = "arn:aws:iam::123456789012:role/CustomerCdkGatewayRole"; + core.gateway.createGatewayTarget = async () => ({ + response: { targetId: "target-1", status: "CREATING" } as never, + rolePolicyWarning: { reason: "unknown-role", roleArn }, + }); + + const { stdout, stderr } = await run( + [ + "gateway", + "target", + "create", + "--gateway-id", + "gateway-1", + "--name", + "lambda", + "--target-configuration", + '{"mcp":{"lambda":{"lambdaArn":"arn:aws:lambda:us-west-2:123456789012:function:orders","toolSchema":{"inlinePayload":[]}}}}', + ], + core, + ); + + expect(JSON.parse(stdout)).toEqual({ targetId: "target-1", status: "CREATING" }); + expect(stderr).toContain(`Execution role ${roleArn} is not recognized`); + expect(stderr).toContain("The CLI did not modify its IAM policies"); + }); +}); diff --git a/src/handlers/gateway/target/create/index.tsx b/src/handlers/gateway/target/create/index.tsx index 7bfa7fd3c..6e178e940 100644 --- a/src/handlers/gateway/target/create/index.tsx +++ b/src/handlers/gateway/target/create/index.tsx @@ -7,7 +7,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"; @@ -118,8 +118,26 @@ export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), }; - ctx - .require(JsonRendererKey) - .renderJson(await core.gateway.createGatewayTarget(input, coreOptsFromCtx(ctx))); + const options = coreOptsFromCtx(ctx); + const preflightWarning = await core.gateway.getGatewayRolePolicyWarning( + input.gatewayIdentifier!, + options, + ); + if (preflightWarning) { + warn( + io, + `Execution role ${preflightWarning.roleArn} is not recognized as AgentCore CLI or console managed. ` + + "The CLI did not modify its IAM policies; you are responsible for this Target's permissions.", + ); + } + const result = await core.gateway.createGatewayTarget(input, options); + if (result.rolePolicyWarning && !preflightWarning) { + warn( + io, + `Execution role ${result.rolePolicyWarning.roleArn} is not recognized as AgentCore CLI or console managed. ` + + "The CLI did not modify its IAM policies; you are responsible for this Target's permissions.", + ); + } + ctx.require(JsonRendererKey).renderJson(result.response); }, }); diff --git a/src/handlers/gateway/types.tsx b/src/handlers/gateway/types.tsx index c414d1c3b..ab1675b93 100644 --- a/src/handlers/gateway/types.tsx +++ b/src/handlers/gateway/types.tsx @@ -24,7 +24,21 @@ export type CreateGatewayTargetInput = CreateGatewayTargetRequest; export type CreateGatewayRuleInput = CreateGatewayRuleRequest; +export type GatewayRolePolicyWarning = { + reason: "unknown-role"; + roleArn: string; +}; + +export type GatewayMutationResult = { + response: T; + rolePolicyWarning?: GatewayRolePolicyWarning; +}; + export interface CoreGatewayClient { + getGatewayRolePolicyWarning( + gatewayId: string, + options: CoreOptions, + ): Promise; createGateway(input: CreateGatewayInput, options: CoreOptions): Promise; getGateway(id: string, options: CoreOptions): Promise; listGateways( @@ -46,7 +60,7 @@ export interface CoreGatewayClient { createGatewayTarget( input: CreateGatewayTargetInput, options: CoreOptions, - ): Promise; + ): Promise>; getGatewayConnector( gatewayId: string, targetId: string, diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index df3414166..1a84d503d 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -887,6 +887,10 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } + async getGatewayRolePolicyWarning(): Promise { + return undefined; + } + async createGateway( input: CreateGatewayInput, options: CoreOptions, @@ -919,10 +923,10 @@ export class TestGatewayClient implements CoreGatewayClient { async createGatewayTarget( input: CreateGatewayTargetInput, options: CoreOptions, - ): Promise { + ): Promise<{ response: CreateGatewayTargetResponse }> { this.calls.push({ method: "createGatewayTarget", args: [input, options] }); if (this.error) throw this.error; - return DEFAULT_CREATE_GATEWAY_TARGET_RESPONSE; + return { response: DEFAULT_CREATE_GATEWAY_TARGET_RESPONSE }; } async getGatewayTarget( diff --git a/src/testing/fixtures.test.tsx b/src/testing/fixtures.test.tsx new file mode 100644 index 000000000..ba7f13ad0 --- /dev/null +++ b/src/testing/fixtures.test.tsx @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + GetRolePolicyCommand, + ListRolePoliciesCommand, + PutRolePolicyCommand, +} from "@aws-sdk/client-iam"; +import { fixtureFactories } from "./fixtures"; +import { stringify } from "./serialization"; + +const directories: string[] = []; + +afterEach(() => { + delete process.env.RECORD; + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function fixturePath(directory: string, command: object & { input: unknown }): string { + const operation = command.constructor.name; + const hash = Bun.hash(stringify(command.input ?? {})).toString(16); + return join(directory, `${operation}.${hash}.json`); +} + +function record(directory: string, command: object & { input: unknown }, response: unknown): void { + writeFileSync(fixturePath(directory, command), stringify(response)); +} + +describe("fixture IAM replay", () => { + test("replays evolving inline policy state while retaining recorded external names", async () => { + const directory = mkdtempSync(join(tmpdir(), "agentcore-fixture-iam-")); + directories.push(directory); + const roleName = "AgentCoreCliGateway-orders"; + const policyName = "AgentCoreCliGatewayExecutionPolicy-test"; + const firstDocument = '{"Version":"2012-10-17","Statement":[]}'; + const secondDocument = + '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}'; + const firstPut = new PutRolePolicyCommand({ + RoleName: roleName, + PolicyName: policyName, + PolicyDocument: firstDocument, + }); + const secondPut = new PutRolePolicyCommand({ + RoleName: roleName, + PolicyName: policyName, + PolicyDocument: secondDocument, + }); + const list = new ListRolePoliciesCommand({ RoleName: roleName }); + record(directory, firstPut, {}); + record(directory, secondPut, {}); + record(directory, list, { + PolicyNames: ["CustomerPolicy"], + IsTruncated: false, + }); + const iam = fixtureFactories(directory).createIamClient({ region: "us-west-2" }); + + await iam.send(firstPut); + await expect(iam.send(list)).resolves.toMatchObject({ + PolicyNames: ["AgentCoreCliGatewayExecutionPolicy-test", "CustomerPolicy"], + }); + await expect( + iam.send(new GetRolePolicyCommand({ RoleName: roleName, PolicyName: policyName })), + ).resolves.toMatchObject({ + PolicyDocument: encodeURIComponent(firstDocument), + }); + + await iam.send(secondPut); + await expect( + iam.send(new GetRolePolicyCommand({ RoleName: roleName, PolicyName: policyName })), + ).resolves.toMatchObject({ + PolicyDocument: encodeURIComponent(secondDocument), + }); + }); +}); diff --git a/src/testing/fixtures.tsx b/src/testing/fixtures.tsx index 49b5dc798..5b8edadd3 100644 --- a/src/testing/fixtures.tsx +++ b/src/testing/fixtures.tsx @@ -1,9 +1,24 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { expect } from "bun:test"; -import type { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore-control"; +import { + CreateGatewayCommand, + CreateGatewayTargetCommand, + DeleteGatewayTargetCommand, + ListGatewayTargetsCommand, + type BedrockAgentCoreControlClient, +} from "@aws-sdk/client-bedrock-agentcore-control"; import type { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; -import type { IAMClient } from "@aws-sdk/client-iam"; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + GetRoleCommand, + GetRolePolicyCommand, + ListRolePoliciesCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; import type { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; import type { ClientConfig, @@ -62,10 +77,23 @@ interface SdkCommand { // staying deterministic and offline-stable across runs. function fixturePath(dir: string, command: SdkCommand): string { const op = command.constructor.name; - const hash = Bun.hash(stringify(command.input ?? {})).toString(16); + const hash = Bun.hash(stringify(normalizeFixtureInput(command.input ?? {}))).toString(16); return join(dir, `${op}.${hash}.json`); } +function normalizeFixtureInput(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalizeFixtureInput); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + key === "clientToken" ? "" : normalizeFixtureInput(entry), + ]), + ); + } + return value; +} + // normalizeResponse strips volatile transport metadata from a recorded SDK // response. `$metadata` holds the HTTP status, retry counts, and a per-request // `requestId` — none of it domain data, all of it non-deterministic across @@ -140,6 +168,178 @@ function makeRecordingSend Promise }>( }; } +function makeIamRecordingSend( + realClient: IAMClient, + dir: string, +): (command: SdkCommand) => Promise { + const recordedSend = makeRecordingSend(realClient, dir); + const inlinePolicies = new Map>(); + const deletedPolicies = new Map>(); + const rolesCreatedInScenario = recordedCreatedRoleNames(dir); + const existingRoles = new Set(); + + return async (command: SdkCommand) => { + if (isRecording()) return recordedSend(command); + + if (command instanceof GetRoleCommand) { + const roleName = command.input.RoleName; + if (roleName && rolesCreatedInScenario.has(roleName) && !existingRoles.has(roleName)) { + const error = new Error(`Role ${roleName} does not exist in replay state.`); + error.name = "NoSuchEntityException"; + throw error; + } + return recordedSend(command); + } + + if (command instanceof CreateRoleCommand) { + const response = await recordedSend(command); + if (command.input.RoleName) existingRoles.add(command.input.RoleName); + return response; + } + + if (command instanceof DeleteRoleCommand) { + const response = await recordedSend(command); + if (command.input.RoleName) existingRoles.delete(command.input.RoleName); + return response; + } + + if (command instanceof PutRolePolicyCommand) { + const response = await recordedSend(command); + const { RoleName, PolicyName, PolicyDocument } = command.input; + if (RoleName && PolicyName && PolicyDocument) { + let policies = inlinePolicies.get(RoleName); + if (!policies) { + policies = new Map(); + inlinePolicies.set(RoleName, policies); + } + policies.set(PolicyName, PolicyDocument); + deletedPolicies.get(RoleName)?.delete(PolicyName); + } + return response; + } + + if (command instanceof DeleteRolePolicyCommand) { + const response = await recordedSend(command); + const { RoleName, PolicyName } = command.input; + if (RoleName && PolicyName) { + inlinePolicies.get(RoleName)?.delete(PolicyName); + let deleted = deletedPolicies.get(RoleName); + if (!deleted) { + deleted = new Set(); + deletedPolicies.set(RoleName, deleted); + } + deleted.add(PolicyName); + } + return response; + } + + if (command instanceof GetRolePolicyCommand) { + const { RoleName, PolicyName } = command.input; + const current = RoleName && PolicyName ? inlinePolicies.get(RoleName)?.get(PolicyName) : null; + if (current) { + return { + RoleName, + PolicyName, + PolicyDocument: encodeURIComponent(current), + }; + } + return recordedSend(command); + } + + if (command instanceof ListRolePoliciesCommand) { + const recorded = (await recordedSend(command)) as { + PolicyNames?: string[]; + IsTruncated?: boolean; + Marker?: string; + }; + const roleName = command.input.RoleName; + const deleted = roleName ? deletedPolicies.get(roleName) : undefined; + const current = roleName ? inlinePolicies.get(roleName) : undefined; + const policyNames = new Set( + (recorded.PolicyNames ?? []).filter((policyName) => !deleted?.has(policyName)), + ); + for (const policyName of current?.keys() ?? []) policyNames.add(policyName); + return { + ...recorded, + PolicyNames: [...policyNames].sort(), + }; + } + + return recordedSend(command); + }; +} + +function recordedCreatedRoleNames(dir: string): Set { + if (!existsSync(dir)) return new Set(); + const names = new Set(); + for (const file of readdirSync(dir).filter((name) => name.startsWith("CreateRoleCommand."))) { + const response = parse(readFileSync(join(dir, file), "utf8")) as { + Role?: { RoleName?: string }; + }; + if (response.Role?.RoleName) names.add(response.Role.RoleName); + } + return names; +} + +function makeControlRecordingSend( + realClient: BedrockAgentCoreControlClient, + dir: string, +): (command: SdkCommand) => Promise { + const recordedSend = makeRecordingSend(realClient, dir); + const targetIdsByGateway = new Map>(); + + return async (command: SdkCommand) => { + if (isRecording()) return recordedSend(command); + + if (command instanceof CreateGatewayCommand) { + const response = (await recordedSend(command)) as { gatewayId?: string }; + if (response.gatewayId) targetIdsByGateway.set(response.gatewayId, new Set()); + return response; + } + + if (command instanceof CreateGatewayTargetCommand) { + const response = (await recordedSend(command)) as { targetId?: string }; + const gatewayId = command.input.gatewayIdentifier; + if (gatewayId && response.targetId) { + let targetIds = targetIdsByGateway.get(gatewayId); + if (!targetIds) { + targetIds = new Set(); + targetIdsByGateway.set(gatewayId, targetIds); + } + targetIds.add(response.targetId); + } + return response; + } + + if (command instanceof DeleteGatewayTargetCommand) { + const response = await recordedSend(command); + const { gatewayIdentifier, targetId } = command.input; + if (gatewayIdentifier && targetId) { + targetIdsByGateway.get(gatewayIdentifier)?.delete(targetId); + } + return response; + } + + if (command instanceof ListGatewayTargetsCommand) { + const recorded = (await recordedSend(command)) as { + items?: { targetId?: string }[]; + }; + const targetIds = command.input.gatewayIdentifier + ? targetIdsByGateway.get(command.input.gatewayIdentifier) + : undefined; + if (!targetIds) return recorded; + return { + ...recorded, + items: (recorded.items ?? []).filter( + (target) => target.targetId && targetIds.has(target.targetId), + ), + }; + } + + return recordedSend(command); + }; +} + // fixtureFactories builds Core client factories backed by the golden files in // `dir`. Drop these into `new CoreClient(...)` to run the real command flow // (parsing → middleware → handler → CoreClient) against recorded data. The fake @@ -156,7 +356,7 @@ export function fixtureFactories(dir: string): { // mode its `.send()` is never reached. const real = createControlClient(config); return { - send: makeRecordingSend(real, dir), + send: makeControlRecordingSend(real, dir), } as unknown as BedrockAgentCoreControlClient; }, createDataClient: (config: ClientConfig) => { @@ -168,7 +368,7 @@ export function fixtureFactories(dir: string): { createIamClient: (config: ClientConfig) => { const real = createIamClient(config); return { - send: makeRecordingSend(real, dir), + send: makeIamRecordingSend(real, dir), } as unknown as IAMClient; }, createLogsClient: (config: ClientConfig) => {