diff --git a/src/core/agentCorePolicyGrants.ts b/src/core/agentCorePolicyGrants.ts index feef44613..3f70b8560 100644 --- a/src/core/agentCorePolicyGrants.ts +++ b/src/core/agentCorePolicyGrants.ts @@ -28,6 +28,22 @@ export class AgentCorePolicyGrants { return allow(["bedrock-agentcore:InvokeGateway"], [gatewayArn]); } + static getWorkloadAccessToken(workloadArns: readonly string[]): GeneratedPolicyStatement { + return allow(["bedrock-agentcore:GetWorkloadAccessToken"], workloadArns); + } + + static getResourceApiKey(providerArn: string): GeneratedPolicyStatement { + return allow(["bedrock-agentcore:GetResourceApiKey"], [providerArn]); + } + + static getResourceOauth2Token(providerArn: string): GeneratedPolicyStatement { + return allow(["bedrock-agentcore:GetResourceOauth2Token"], [providerArn]); + } + + static readSecret(secretArn: string): GeneratedPolicyStatement { + return allow(["secretsmanager:GetSecretValue"], [secretArn]); + } + static invokeRuntime(runtimeArns: readonly string[]): GeneratedPolicyStatement { return allow(["bedrock-agentcore:InvokeAgentRuntime"], runtimeArns); } @@ -85,6 +101,30 @@ export class AgentCorePolicyGrants { return allow(["bedrock:GetKnowledgeBase", "bedrock:Retrieve"], [knowledgeBaseArn]); } + static getKnowledgeBases(knowledgeBaseArns: readonly string[]): GeneratedPolicyStatement { + return allow(["bedrock:GetKnowledgeBase"], knowledgeBaseArns); + } + + static retrieveKnowledgeBases(knowledgeBaseArns: readonly string[]): GeneratedPolicyStatement { + return allow(["bedrock:Retrieve"], knowledgeBaseArns); + } + + static agenticRetrieveKnowledgeBases(): GeneratedPolicyStatement { + return allow(["bedrock:AgenticRetrieveStream"], ["*"]); + } + + static createMantleInference(projectArns: readonly string[]): GeneratedPolicyStatement { + return allow(["bedrock-mantle:CreateInference"], projectArns); + } + + static listMantleModels(projectArn: string): GeneratedPolicyStatement { + return allow(["bedrock-mantle:ListModels"], [projectArn]); + } + + static callMantleWithBearerToken(): GeneratedPolicyStatement { + return allow(["bedrock-mantle:CallWithBearerToken"], ["*"]); + } + static decryptKmsKey(keyArn: string): GeneratedPolicyStatement { return allow(["kms:Decrypt", "kms:DescribeKey"], [keyArn]); } diff --git a/src/core/executionRoleManager.test.ts b/src/core/executionRoleManager.test.ts index 2f91d7daa..728795101 100644 --- a/src/core/executionRoleManager.test.ts +++ b/src/core/executionRoleManager.test.ts @@ -327,6 +327,56 @@ describe("ExecutionRoleManager role lifecycle", () => { } }); + test("accepts standard trust conditions when the Gateway context matches", async () => { + const gatewayArn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc123"; + const iam = { + send: async () => ({ + Role: { + Arn: roleArn("AmazonBedrockAgentCoreGatewayDefaultServiceRole"), + RoleName: "AmazonBedrockAgentCoreGatewayDefaultServiceRole", + AssumeRolePolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + Condition: { + StringEquals: { "aws:SourceAccount": ACCOUNT }, + ArnLike: { + "aws:SourceArn": "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/*", + }, + }, + }, + ], + }), + }, + }), + } as unknown as IAMClient; + + await expect( + new ExecutionRoleManager(iam).validateAgentCoreTrust( + "AmazonBedrockAgentCoreGatewayDefaultServiceRole", + { + sourceAccount: ACCOUNT, + sourceArn: gatewayArn, + }, + ), + ).resolves.toMatchObject({ + arn: roleArn("AmazonBedrockAgentCoreGatewayDefaultServiceRole"), + created: false, + }); + await expect( + new ExecutionRoleManager(iam).validateAgentCoreTrust( + "AmazonBedrockAgentCoreGatewayDefaultServiceRole", + { + sourceAccount: "000000000000", + sourceArn: gatewayArn, + }, + ), + ).rejects.toBeInstanceOf(ExecutionRoleTrustError); + }); + test("rolls back only a role created by the current parent create", async () => { const sent: (DeleteRoleCommand | DeleteRolePolicyCommand)[] = []; const iam = { diff --git a/src/core/executionRoleManager.ts b/src/core/executionRoleManager.ts index 79866dbff..ebe8fedb9 100644 --- a/src/core/executionRoleManager.ts +++ b/src/core/executionRoleManager.ts @@ -70,6 +70,11 @@ export type ExecutionRoleManagerOptions = { sleep?: (milliseconds: number) => Promise; }; +export type AgentCoreTrustContext = { + sourceAccount: string; + sourceArn: string; +}; + export class ExecutionRoleTrustError extends Error { constructor(readonly roleName: string) { super(`IAM role ${roleName} does not allow bedrock-agentcore.amazonaws.com to assume it.`); @@ -172,9 +177,12 @@ export class ExecutionRoleManager { return { arn: roleArn, name: roleName, created: true }; } - async validateAgentCoreTrust(roleName: string): Promise { + async validateAgentCoreTrust( + roleName: string, + context?: AgentCoreTrustContext, + ): Promise { const response = await this.iam.send(new GetRoleCommand({ RoleName: roleName })); - return this.existingRole(response.Role, roleName); + return this.existingRole(response.Role, roleName, context); } async rollbackCreatedRole( @@ -232,13 +240,17 @@ export class ExecutionRoleManager { } } - private existingRole(role: Role | undefined, expectedRoleName: string): ManagedExecutionRole { + private existingRole( + role: Role | undefined, + expectedRoleName: string, + context?: AgentCoreTrustContext, + ): ManagedExecutionRole { const roleArn = requiredRoleArn(role, expectedRoleName); let trusted = false; try { trusted = role?.AssumeRolePolicyDocument !== undefined && - allowsAgentCoreAssumeRole(role.AssumeRolePolicyDocument); + allowsAgentCoreAssumeRole(role.AssumeRolePolicyDocument, context); } catch { trusted = false; } @@ -297,7 +309,10 @@ function requiredRoleArn(role: Role | undefined, roleName: string): string { return role.Arn; } -function allowsAgentCoreAssumeRole(policyDocument: string): boolean { +function allowsAgentCoreAssumeRole( + policyDocument: string, + context?: AgentCoreTrustContext, +): boolean { const policy = parseIamDocument(policyDocument); const statements = Array.isArray(policy.Statement) ? policy.Statement : [policy.Statement]; const applicable = statements.filter((statement) => { @@ -313,10 +328,46 @@ function allowsAgentCoreAssumeRole(policyDocument: string): boolean { }); if (applicable.some((statement) => statement.Effect === "Deny")) return false; return applicable.some( - (statement) => statement.Effect === "Allow" && statement.Condition === undefined, + (statement) => + statement.Effect === "Allow" && + (statement.Condition === undefined || trustConditionsMatch(statement.Condition, context)), ); } +function trustConditionsMatch( + condition: unknown, + context: AgentCoreTrustContext | undefined, +): boolean { + if (!context || !isRecord(condition)) return false; + + for (const [operator, entries] of Object.entries(condition)) { + if (!isRecord(entries)) return false; + for (const [key, expected] of Object.entries(entries)) { + const normalizedKey = key.toLowerCase(); + const actual = + normalizedKey === "aws:sourceaccount" + ? context.sourceAccount + : normalizedKey === "aws:sourcearn" + ? context.sourceArn + : undefined; + if (!actual) return false; + const expectedValues = stringList(expected); + if (expectedValues.length === 0) return false; + if (operator === "StringEquals" || operator === "ArnEquals") { + if (!expectedValues.includes(actual)) return false; + continue; + } + if (operator === "StringLike" || operator === "ArnLike") { + if (!expectedValues.some((pattern) => iamGlobMatches(pattern, actual))) return false; + continue; + } + return false; + } + } + + return true; +} + function statementAppliesToAssumeRole(statement: Record): boolean { if (statement.Action !== undefined) { return stringList(statement.Action).some((pattern) => diff --git a/src/core/executionRolePolicyUpdater.test.ts b/src/core/executionRolePolicyUpdater.test.ts index d0bdb7163..8b75cb8f3 100644 --- a/src/core/executionRolePolicyUpdater.test.ts +++ b/src/core/executionRolePolicyUpdater.test.ts @@ -12,6 +12,8 @@ import { PolicyDriftError, PolicyFinalizationError, PolicyOperationOutcomeUnknownError, + PolicyRemovalFinalizationError, + PolicyRemovalOutcomeUnknownError, RoleInlinePolicyQuotaError, } from "./executionRolePolicyUpdater"; @@ -494,6 +496,82 @@ describe("ExecutionRolePolicyUpdater", () => { expect(writes).toHaveLength(1); }); + test("removes a generated policy only after a parent deletion succeeds", async () => { + const events: string[] = []; + const { iam } = statefulIam(events); + const updater = new ExecutionRolePolicyUpdater(iam, { + propagationDelayMs: 0, + retryDelayMs: 0, + }); + + const value = await updater.removeAfter({ + roleName: ROLE_NAME, + policyName: POLICY_NAME, + operation: async () => { + events.push("AgentCoreDelete"); + return { gatewayId: "gateway-1" }; + }, + }); + + expect(value).toEqual({ gatewayId: "gateway-1" }); + expect(events).toEqual(["AgentCoreDelete", "DeleteRolePolicyCommand", "GetRolePolicyCommand"]); + }); + + test("retains the generated policy when parent deletion outcome is unknown", async () => { + const events: string[] = []; + const { iam } = statefulIam(events); + const updater = new ExecutionRolePolicyUpdater(iam, { + propagationDelayMs: 0, + retryDelayMs: 0, + }); + const timeout = new Error("delete response timed out"); + timeout.name = "GatewayOutcomeUnknownError"; + + const error = await updater + .removeAfter({ + roleName: ROLE_NAME, + policyName: POLICY_NAME, + operation: async () => { + events.push("AgentCoreDelete"); + throw timeout; + }, + isOperationOutcomeUnknown: (caught) => + (caught as Error).name === "GatewayOutcomeUnknownError", + }) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(PolicyRemovalOutcomeUnknownError); + expect((error as PolicyRemovalOutcomeUnknownError).cause).toBe(timeout); + expect(events).toEqual(["AgentCoreDelete"]); + }); + + test("reports exact manual repair coordinates when policy removal fails", async () => { + const iam = { + send: async (command: SentCommand) => { + if (command instanceof DeleteRolePolicyCommand) { + throw new Error("IAM denied cleanup"); + } + throw new Error(`unexpected IAM command ${command.constructor.name}`); + }, + } as unknown as IAMClient; + const updater = new ExecutionRolePolicyUpdater(iam, { + propagationDelayMs: 0, + retryDelayMs: 0, + }); + + const error = await updater + .removeAfter({ + roleName: ROLE_NAME, + policyName: POLICY_NAME, + operation: async () => ({ gatewayId: "gateway-1" }), + }) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(PolicyRemovalFinalizationError); + expect((error as Error).message).toContain(POLICY_NAME); + expect((error as Error).message).toContain(ROLE_NAME); + }); + test("paginates customer policies and excludes insignificant JSON whitespace from quota", async () => { const events: string[] = []; let generatedPolicy: string | undefined; diff --git a/src/core/executionRolePolicyUpdater.ts b/src/core/executionRolePolicyUpdater.ts index 765d830b8..cb2df16d2 100644 --- a/src/core/executionRolePolicyUpdater.ts +++ b/src/core/executionRolePolicyUpdater.ts @@ -53,6 +53,13 @@ export type ExecutionRolePolicyUpdateResult = { tightened: boolean; }; +export type ExecutionRolePolicyRemoval = { + roleName: string; + policyName: string; + operation: () => Promise; + isOperationOutcomeUnknown?: (error: unknown) => boolean; +}; + export class PolicyPropagationError extends Error { constructor( readonly roleName: string, @@ -139,6 +146,38 @@ export class PolicyFinalizationError extends Error { } } +export class PolicyRemovalOutcomeUnknownError extends Error { + constructor( + readonly roleName: string, + readonly policyName: string, + options: ErrorOptions, + ) { + super( + "AgentCore deletion outcome is unknown; the generated execution-role policy was retained. " + + "Inspect the resource before retrying.", + options, + ); + this.name = "PolicyRemovalOutcomeUnknownError"; + } +} + +export class PolicyRemovalFinalizationError extends Error { + constructor( + readonly value: T, + readonly roleName: string, + readonly policyName: string, + options: ErrorOptions, + ) { + const identity = resourceIdentity(value); + super( + `AgentCore deletion succeeded${identity ? ` for ${identity}` : ""}, but generated execution-role policy cleanup failed. ` + + `Remove policy ${policyName} from role ${roleName} manually; do not delete the role.`, + options, + ); + this.name = "PolicyRemovalFinalizationError"; + } +} + export class ExecutionRolePolicyUpdater { private readonly compiler: PolicyCompiler; private readonly maxVisibilityAttempts: number; @@ -163,6 +202,34 @@ export class ExecutionRolePolicyUpdater { return rolePolicyTransactions.run(request.roleName, () => this.updateUnlocked(request)); } + async removeAfter(request: ExecutionRolePolicyRemoval): Promise { + return rolePolicyTransactions.run(request.roleName, () => this.removeAfterUnlocked(request)); + } + + private async removeAfterUnlocked(request: ExecutionRolePolicyRemoval): Promise { + let value: T; + try { + value = await request.operation(); + } catch (error) { + if (request.isOperationOutcomeUnknown?.(error)) { + throw new PolicyRemovalOutcomeUnknownError(request.roleName, request.policyName, { + cause: error, + }); + } + throw error; + } + + try { + await this.deletePolicy(request.roleName, request.policyName); + await this.waitUntilAbsent(request.roleName, request.policyName); + } catch (error) { + throw new PolicyRemovalFinalizationError(value, request.roleName, request.policyName, { + cause: error, + }); + } + return value; + } + private async updateUnlocked( request: ExecutionRolePolicyUpdate, ): Promise> { diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index 2322c0c96..57b2d5052 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -1,12 +1,21 @@ import { describe, expect, mock, test } from "bun:test"; import { + DeleteGatewayCommand, + DeleteGatewayRuleCommand, + DeleteGatewayTargetCommand, + GetGatewayCommand, GetGatewayTargetCommand, ListGatewayTargetsCommand, TargetType, + UpdateGatewayCommand, + UpdateGatewayTargetCommand, + type BedrockAgentCoreControlClient, + type GetGatewayResponse, type GetGatewayTargetResponse, type TargetSummary, } from "@aws-sdk/client-bedrock-agentcore-control"; -import { ResultTruncationError } from "../errors"; +import { ERROR_SOURCE, ResultTruncationError } from "../errors"; +import type { GatewayTargetUpdatePatch, GatewayUpdatePatch } from "../handlers/gateway/types"; import type { AwsClients } from "./types"; import { GatewayClient } from "./gateway"; @@ -217,3 +226,329 @@ describe("GatewayClient Connector facade", () => { ); }); }); + +const OPTIONS = { region: "us-west-2" }; + +function gateway(): GetGatewayResponse { + return { + gatewayId: "gateway-1", + name: "orders", + roleArn: "arn:aws:iam::123456789012:role/orders", + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJWTAuthorizer: { + discoveryUrl: "https://auth.example.test/.well-known/openid-configuration", + }, + }, + protocolType: "MCP", + protocolConfiguration: { mcp: { supportedVersions: ["2025-11-25"] } }, + description: "before", + kmsKeyArn: "arn:aws:kms:us-west-2:123456789012:key/key-1", + policyEngineConfiguration: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/engine-1", + mode: "LOG_ONLY", + }, + exceptionLevel: "DEBUG", + } as GetGatewayResponse; +} + +function target(): GetGatewayTargetResponse { + return { + targetId: "target-1", + name: "calendar", + description: "before", + targetConfiguration: { + mcp: { + mcpServer: { + endpoint: "https://old.example.test/mcp", + mcpToolSchema: { s3: { uri: "s3://schemas/calendar.json" } }, + listingMode: "DEFAULT", + resourcePriority: 100, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "JWT_PASSTHROUGH" }], + metadataConfiguration: { allowedRequestHeaders: ["x-request-id"] }, + } as unknown as GetGatewayTargetResponse; +} + +test("maps Gateway, Target, and Rule selectors to their delete commands", async () => { + const gatewayMissing = new Error("missing"); + gatewayMissing.name = "ResourceNotFoundException"; + const targetMissing = new Error("missing"); + targetMissing.name = "ResourceNotFoundException"; + const { client, commands } = recordingGatewayClient([ + gateway(), + {}, + gatewayMissing, + gateway(), + {}, + targetMissing, + {}, + ]); + + await client.deleteGateway("gateway-1", OPTIONS); + await client.deleteGatewayTarget("gateway-1", "target-1", OPTIONS); + await client.deleteGatewayRule("gateway-1", "rule-1", OPTIONS); + + expect(commands).toHaveLength(7); + expect(commands[1]).toBeInstanceOf(DeleteGatewayCommand); + expect((commands[1] as DeleteGatewayCommand).input).toEqual({ + gatewayIdentifier: "gateway-1", + }); + expect(commands[4]).toBeInstanceOf(DeleteGatewayTargetCommand); + expect((commands[4] as DeleteGatewayTargetCommand).input).toEqual({ + gatewayIdentifier: "gateway-1", + targetId: "target-1", + }); + expect(commands[6]).toBeInstanceOf(DeleteGatewayRuleCommand); + expect((commands[6] as DeleteGatewayRuleCommand).input).toEqual({ + gatewayIdentifier: "gateway-1", + ruleId: "rule-1", + }); +}); + +function recordingGatewayClient(responses: unknown[]): { + client: GatewayClient; + commands: unknown[]; +} { + const commands: unknown[] = []; + const control = { + send: async (command: unknown) => { + commands.push(command); + const response = responses.shift(); + if (response instanceof Error) throw response; + return response; + }, + } as unknown as BedrockAgentCoreControlClient; + const clients: AwsClients = { + control: () => control, + data: () => { + throw new Error("unexpected data client"); + }, + iam: () => { + throw new Error("unexpected IAM client"); + }, + logs: () => { + throw new Error("unexpected Logs client"); + }, + }; + return { client: new GatewayClient(clients), commands }; +} + +async function gatewayUpdateInput( + patch: GatewayUpdatePatch, + current: GetGatewayResponse = gateway(), +): Promise { + const { client, commands } = recordingGatewayClient([ + current, + { ...current, status: "UPDATING" }, + { ...current, status: "READY" }, + ]); + await client.updateGateway(patch, OPTIONS); + expect(commands[0]).toBeInstanceOf(GetGatewayCommand); + expect((commands[0] as GetGatewayCommand).input).toEqual({ + gatewayIdentifier: patch.id, + }); + return (commands[1] as UpdateGatewayCommand).input; +} + +async function targetUpdateInput( + patch: GatewayTargetUpdatePatch, + current: GetGatewayTargetResponse = target(), +): Promise { + const { client, commands } = recordingGatewayClient([ + current, + gateway(), + { ...current, status: "UPDATING" }, + { ...current, status: "READY" }, + ]); + await client.updateGatewayTarget(patch, OPTIONS); + expect(commands[0]).toBeInstanceOf(GetGatewayTargetCommand); + expect((commands[0] as GetGatewayTargetCommand).input).toEqual({ + gatewayIdentifier: patch.gatewayId, + targetId: patch.targetId, + }); + return (commands[2] as UpdateGatewayTargetCommand).input; +} + +describe("GatewayClient updateGateway", () => { + test("clears requested fields and merges a Policy Engine mode change", async () => { + expect( + await gatewayUpdateInput({ + id: "gateway-1", + clearProtocol: true, + description: null, + protocolConfiguration: null, + policyEngineConfiguration: { mode: "ENFORCE" }, + exceptionLevel: null, + }), + ).toEqual({ + gatewayIdentifier: "gateway-1", + name: "orders", + roleArn: "arn:aws:iam::123456789012:role/orders", + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJWTAuthorizer: { + discoveryUrl: "https://auth.example.test/.well-known/openid-configuration", + }, + }, + kmsKeyArn: "arn:aws:kms:us-west-2:123456789012:key/key-1", + policyEngineConfiguration: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/engine-1", + mode: "ENFORCE", + }, + }); + }); + + test("rejects CUSTOM_JWT configuration on another authorizer type", async () => { + const { client } = recordingGatewayClient([ + { ...gateway(), authorizerType: "NONE", authorizerConfiguration: undefined }, + ]); + await expect( + client.updateGateway( + { + id: "gateway-1", + authorizerConfiguration: { + customJWTAuthorizer: { + discoveryUrl: "https://auth.example.test/.well-known/openid-configuration", + }, + }, + }, + OPTIONS, + ), + ).rejects.toThrow(/CUSTOM_JWT/); + }); + + test("classifies missing required service fields as service errors", async () => { + const { client } = recordingGatewayClient([{ ...gateway(), name: undefined }]); + + await expect( + client.updateGateway({ id: "gateway-1", description: "after" }, OPTIONS), + ).rejects.toMatchObject({ source: ERROR_SOURCE.SERVICE }); + }); +}); + +describe("GatewayClient updateGatewayTarget", () => { + test("updates an MCP endpoint while preserving its schema and ancillary fields", async () => { + expect( + await targetUpdateInput({ + gatewayId: "gateway-1", + targetId: "target-1", + endpoint: "https://new.example.test/mcp", + }), + ).toEqual({ + gatewayIdentifier: "gateway-1", + targetId: "target-1", + name: "calendar", + description: "before", + targetConfiguration: { + mcp: { + mcpServer: { + endpoint: "https://new.example.test/mcp", + mcpToolSchema: { s3: { uri: "s3://schemas/calendar.json" } }, + listingMode: "DEFAULT", + resourcePriority: 100, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "JWT_PASSTHROUGH" }], + metadataConfiguration: { allowedRequestHeaders: ["x-request-id"] }, + }); + }); + + test("clears optional fields while preserving the Target configuration", async () => { + expect( + await targetUpdateInput({ + gatewayId: "gateway-1", + targetId: "target-1", + description: null, + credentialProviderConfigurations: null, + metadataConfiguration: null, + }), + ).toEqual({ + gatewayIdentifier: "gateway-1", + targetId: "target-1", + name: "calendar", + targetConfiguration: target().targetConfiguration, + }); + }); + + test("rejects endpoint shorthand for a non-MCP-server Target", async () => { + const { client } = recordingGatewayClient([ + { + targetId: "target-1", + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://example.test", + protocolType: "CUSTOM", + }, + }, + }, + } as GetGatewayTargetResponse, + ]); + await expect( + client.updateGatewayTarget( + { + gatewayId: "gateway-1", + targetId: "target-1", + endpoint: "https://new.example.test/mcp", + }, + OPTIONS, + ), + ).rejects.toThrow(/existing MCP server Target/); + }); +}); + +describe("GatewayClient updateGatewayConnector", () => { + test("updates an existing inference connector Target", async () => { + const targetConfiguration = { + inference: { connector: { source: { connectorId: "bedrock-mantle" } } }, + }; + const { client, commands } = recordingGatewayClient([ + { targetId: "target-1", targetConfiguration } as GetGatewayTargetResponse, + gateway(), + { + targetId: "target-1", + targetConfiguration, + status: "UPDATING", + }, + { + targetId: "target-1", + targetConfiguration, + status: "READY", + }, + ]); + + await client.updateGatewayConnector( + { + gatewayId: "gateway-1", + targetId: "target-1", + description: "after", + }, + OPTIONS, + ); + + expect(commands[2]).toBeInstanceOf(UpdateGatewayTargetCommand); + expect((commands[2] as UpdateGatewayTargetCommand).input.targetConfiguration).toEqual( + targetConfiguration, + ); + }); + + test("rejects an existing non-connector Target", async () => { + const { client, commands } = recordingGatewayClient([target()]); + + await expect( + client.updateGatewayConnector( + { + gatewayId: "gateway-1", + targetId: "target-1", + description: "after", + }, + OPTIONS, + ), + ).rejects.toThrow(/not connector-backed/); + expect(commands).toHaveLength(1); + }); +}); diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index f1fcb79d8..4cbf158fb 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -3,42 +3,72 @@ import { CreateGatewayCommand, CreateGatewayRuleCommand, CreateGatewayTargetCommand, + DeleteGatewayCommand, + DeleteGatewayRuleCommand, + DeleteGatewayTargetCommand, + GetApiKeyCredentialProviderCommand, GetGatewayCommand, GetGatewayRuleCommand, GetGatewayTargetCommand, + GetOauth2CredentialProviderCommand, ListGatewayRulesCommand, ListGatewaysCommand, ListGatewayTargetsCommand, TargetType, + UpdateGatewayCommand, + UpdateGatewayRuleCommand, + UpdateGatewayTargetCommand, type CreateGatewayResponse, type CreateGatewayRuleResponse, type CreateGatewayTargetResponse, + type DeleteGatewayResponse, + type DeleteGatewayRuleResponse, + type DeleteGatewayTargetResponse, type GetGatewayResponse, type GetGatewayRuleResponse, type GetGatewayTargetResponse, + type CredentialProviderConfiguration, type ListGatewayRulesResponse, type ListGatewaysResponse, type ListGatewayTargetsResponse, type TargetConfiguration, type TargetSummary, + type UpdateGatewayRequest, + type UpdateGatewayResponse, + type UpdateGatewayRuleResponse, + type UpdateGatewayTargetRequest, + type UpdateGatewayTargetResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import { InputValidationError, ResultTruncationError } from "../errors"; +import { + AgentCoreCLIError, + ERROR_SOURCE, + InputValidationError, + ResultTruncationError, +} from "../errors"; import type { CoreGatewayClient, CreateGatewayInput, CreateGatewayRuleInput, CreateGatewayTargetInput, GatewayMutationResult, + GatewayRuleUpdateInput, + GatewayTargetUpdatePatch, + GatewayUpdatePatch, } from "../handlers/gateway/types"; import type { AwsClients, CoreOptions } from "./types"; -import { ExecutionRoleManager, type ExecutionRoleManagerOptions } from "./executionRoleManager"; +import { + ExecutionRoleManager, + type ExecutionRoleManagerOptions, + type ExecutionRolePolicyManagement, +} from "./executionRoleManager"; +import type { PolicyContribution } from "./executionRolePolicy"; import { ExecutionRolePolicyUpdater, PolicyFinalizationError, PolicyOperationOutcomeUnknownError, type ExecutionRolePolicyUpdaterOptions, } from "./executionRolePolicyUpdater"; -import { GatewayPolicyPlanner } from "./gatewayPolicy"; +import { GatewayPolicyPlanner, type GatewayCredentialProviderPolicyState } from "./gatewayPolicy"; import { toClientConfig } from "./utils"; const DEFAULT_CONNECTOR_PAGE_SIZE = 100; @@ -54,6 +84,14 @@ export type GatewayClientOptions = { sleep?: (milliseconds: number) => Promise; }; +type GatewayPolicyInventory = { + gateway: GetGatewayResponse; + targets: GetGatewayTargetResponse[]; + credentialProviders: GatewayCredentialProviderPolicyState[]; +}; + +type ManagedGatewayPolicy = Extract; + export class GatewayTerminalStateError extends Error { constructor( readonly gatewayId: string, @@ -172,20 +210,13 @@ export class GatewayClient implements CoreGatewayClient { 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, - }), + resolveDesired: async ({ settled }) => { + const state = await this.readGatewayPolicyState(settled.gatewayId!, options, settled); + return { + contributions: this.planGatewayPolicy(state), + inventoryComplete: true, + }; + }, }); return result.value.response; } catch (error) { @@ -221,6 +252,128 @@ export class GatewayClient implements CoreGatewayClient { .send(new GetGatewayCommand({ gatewayIdentifier: id })); } + async updateGateway( + patch: GatewayUpdatePatch, + options: CoreOptions, + ): Promise { + const control = this.clients.control(toClientConfig(options)); + const current = await control.send(new GetGatewayCommand({ gatewayIdentifier: patch.id })); + const resource = `Gateway "${patch.id}"`; + const name = GatewayClient.required(current.name, resource, "name"); + const roleArn = GatewayClient.required(current.roleArn, resource, "role ARN"); + const authorizerType = GatewayClient.required( + current.authorizerType, + resource, + "authorizer type", + ); + if (patch.authorizerConfiguration !== undefined && authorizerType !== "CUSTOM_JWT") { + throw new InputValidationError( + "Authorizer configuration can only be updated for a CUSTOM_JWT Gateway", + ); + } + + let policyEngineConfiguration = current.policyEngineConfiguration; + if (patch.policyEngineConfiguration === null) { + policyEngineConfiguration = undefined; + } else if (patch.policyEngineConfiguration !== undefined) { + const arn = patch.policyEngineConfiguration.arn ?? current.policyEngineConfiguration?.arn; + const mode = patch.policyEngineConfiguration.mode ?? current.policyEngineConfiguration?.mode; + if (!arn || !mode) { + throw new InputValidationError( + "Policy Engine update requires an ARN and mode, either existing or supplied", + ); + } + policyEngineConfiguration = { arn, mode }; + } + + const description = GatewayClient.replace(current.description, patch.description); + const protocolConfiguration = GatewayClient.replace( + current.protocolConfiguration, + patch.protocolConfiguration, + ); + const customTransformConfiguration = GatewayClient.replace( + current.customTransformConfiguration, + patch.customTransformConfiguration, + ); + const interceptorConfigurations = GatewayClient.replace( + current.interceptorConfigurations, + patch.interceptorConfigurations, + ); + const exceptionLevel = GatewayClient.replace(current.exceptionLevel, patch.exceptionLevel); + const wafConfiguration = GatewayClient.replace( + current.wafConfiguration, + patch.wafConfiguration, + ); + const request: UpdateGatewayRequest = { + gatewayIdentifier: patch.id, + name, + roleArn: patch.roleArn ?? roleArn, + authorizerType, + description, + protocolType: patch.clearProtocol ? undefined : current.protocolType, + protocolConfiguration, + authorizerConfiguration: patch.authorizerConfiguration ?? current.authorizerConfiguration, + kmsKeyArn: current.kmsKeyArn, + customTransformConfiguration, + interceptorConfigurations, + policyEngineConfiguration, + exceptionLevel, + wafConfiguration, + }; + const management = ExecutionRoleManager.policyManagement({ + associatedRoleArn: roleArn, + explicitRoleArn: patch.roleArn, + skipPolicyUpdate: patch.skipRolePolicyUpdate, + }); + if (management.mode === "external") { + if (management.reason === "explicit-role" && !patch.skipRolePolicyUpdate) { + const previousManagement = ExecutionRoleManager.policyManagement({ + associatedRoleArn: roleArn, + }); + if (previousManagement.mode === "managed") { + const policyName = gatewayPolicyName( + previousManagement.roleName, + previousManagement.roleArn, + current.gatewayId ?? patch.id, + options.region, + ); + const policyUpdater = new ExecutionRolePolicyUpdater( + this.clients.iam({ region: options.region }), + { + propagationDelayMs: 10_000, + ...this.options.policyUpdater, + }, + ); + const result = await policyUpdater.removeAfter({ + roleName: previousManagement.roleName, + policyName, + operation: () => this.updateGatewayAndWait(request, options), + isOperationOutcomeUnknown: (error) => error instanceof GatewayOutcomeUnknownError, + }); + return result.response; + } + } + return (await this.updateGatewayAndWait(request, options)).response; + } + + const currentState = await this.readGatewayPolicyState(patch.id, options, current); + const desired = this.planGatewayPolicy(currentState, { + policyEngineConfiguration, + interceptorConfigurations, + customTransformConfiguration, + }); + const result = await this.reconcileManagedPolicy({ + gatewayId: patch.id, + management, + currentState, + desired, + operation: () => this.updateGatewayAndWait(request, options), + retryPropagation: true, + options, + }); + return result.response; + } + async listGateways( nextToken: string | undefined, maxResults: number | undefined, @@ -231,6 +384,41 @@ export class GatewayClient implements CoreGatewayClient { .send(new ListGatewaysCommand({ nextToken, maxResults })); } + async deleteGateway(id: string, options: CoreOptions): Promise { + const gateway = await this.getGateway(id, options); + if (!gateway.roleArn) { + throw new Error(`Gateway ${id} returned no execution role ARN.`); + } + const request = { gatewayIdentifier: id }; + const management = ExecutionRoleManager.policyManagement({ + associatedRoleArn: gateway.roleArn, + }); + if (management.mode === "external") { + return (await this.deleteGatewayAndWait(request, options)).response; + } + + const policyName = gatewayPolicyName( + management.roleName, + management.roleArn, + gateway.gatewayId ?? id, + options.region, + ); + const policyUpdater = new ExecutionRolePolicyUpdater( + this.clients.iam({ region: options.region }), + { + propagationDelayMs: 10_000, + ...this.options.policyUpdater, + }, + ); + const result = await policyUpdater.removeAfter({ + roleName: management.roleName, + policyName, + operation: () => this.deleteGatewayAndWait(request, options), + isOperationOutcomeUnknown: (error) => error instanceof GatewayOutcomeUnknownError, + }); + return result.response; + } + async getGatewayTarget( gatewayId: string, targetId: string, @@ -316,49 +504,29 @@ export class GatewayClient implements CoreGatewayClient { 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 desiredTargets = [...currentState.targets, proposedTarget]; + const desiredCredentialProviders = await this.resolveCredentialProviderPolicyState( + desiredTargets, + options, + ); + const desired = this.planGatewayPolicy(currentState, { + credentialProviders: desiredCredentialProviders, + targets: desiredTargets, }); const request = { ...input, clientToken: input.clientToken ?? randomUUID(), }; - const result = await policyUpdater.update({ - roleName: management.roleName, - policyName, - current, + const response = await this.reconcileManagedPolicy({ + gatewayId: input.gatewayIdentifier!, + management, + currentState, desired, operation: async () => { let response: CreateGatewayTargetResponse; @@ -380,27 +548,10 @@ export class GatewayClient implements CoreGatewayClient { 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, - }; - }, + retryPropagation: true, + options, }); - return { response: result.value }; + return { response }; } async getGatewayConnector( @@ -452,6 +603,52 @@ export class GatewayClient implements CoreGatewayClient { ); } + async updateGatewayTarget( + patch: GatewayTargetUpdatePatch, + options: CoreOptions, + ): Promise { + return this.updateTarget(patch, options, false); + } + + async updateGatewayConnector( + patch: GatewayTargetUpdatePatch, + options: CoreOptions, + ): Promise { + return this.updateTarget(patch, options, true); + } + + async deleteGatewayTarget( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise { + const gateway = await this.getGateway(gatewayId, options); + if (!gateway.roleArn) { + throw new Error(`Gateway ${gatewayId} returned no execution role ARN.`); + } + const request = { + gatewayIdentifier: gatewayId, + targetId, + }; + const management = ExecutionRoleManager.policyManagement({ + associatedRoleArn: gateway.roleArn, + }); + if (management.mode === "external") { + return (await this.deleteGatewayTargetAndWait(request, options)).response; + } + + const currentState = await this.readGatewayPolicyState(gatewayId, options, gateway); + const result = await this.reconcileManagedPolicy({ + gatewayId, + management, + currentState, + desired: this.planGatewayPolicy(currentState), + operation: () => this.deleteGatewayTargetAndWait(request, options), + options, + }); + return result.response; + } + async getGatewayRule( gatewayId: string, ruleId: string, @@ -487,6 +684,153 @@ export class GatewayClient implements CoreGatewayClient { return this.clients.control(toClientConfig(options)).send(new CreateGatewayRuleCommand(input)); } + async updateGatewayRule( + input: GatewayRuleUpdateInput, + options: CoreOptions, + ): Promise { + return this.clients.control(toClientConfig(options)).send(new UpdateGatewayRuleCommand(input)); + } + + async deleteGatewayRule( + gatewayId: string, + ruleId: string, + options: CoreOptions, + ): Promise { + return this.clients.control(toClientConfig(options)).send( + new DeleteGatewayRuleCommand({ + gatewayIdentifier: gatewayId, + ruleId, + }), + ); + } + + private async updateTarget( + patch: GatewayTargetUpdatePatch, + options: CoreOptions, + connectorOnly: boolean, + ): Promise { + const control = this.clients.control(toClientConfig(options)); + const current = await control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: patch.gatewayId, + targetId: patch.targetId, + }), + ); + const currentTargetConfiguration = GatewayClient.required( + current.targetConfiguration, + `Gateway Target "${patch.targetId}"`, + "configuration", + ); + if (connectorOnly && !GatewayClient.isConnectorTarget(currentTargetConfiguration)) { + throw new InputValidationError(`Gateway Target "${patch.targetId}" is not connector-backed`); + } + + let targetConfiguration = patch.targetConfiguration; + if (targetConfiguration === undefined && patch.endpoint !== undefined) { + const mcpServer = currentTargetConfiguration.mcp?.mcpServer; + if (!mcpServer) { + throw new InputValidationError("Endpoint updates require an existing MCP server Target"); + } + targetConfiguration = { + mcp: { + mcpServer: { + ...mcpServer, + endpoint: patch.endpoint, + }, + }, + }; + } + targetConfiguration ??= currentTargetConfiguration; + + const name = GatewayClient.replace(current.name, patch.name); + const description = GatewayClient.replace(current.description, patch.description); + const credentialProviderConfigurations = GatewayClient.replace( + current.credentialProviderConfigurations, + patch.credentialProviderConfigurations, + ); + const metadataConfiguration = GatewayClient.replace( + current.metadataConfiguration, + patch.metadataConfiguration, + ); + const privateEndpoint = GatewayClient.replace(current.privateEndpoint, patch.privateEndpoint); + const request: UpdateGatewayTargetRequest = { + gatewayIdentifier: patch.gatewayId, + targetId: patch.targetId, + targetConfiguration, + name, + description, + credentialProviderConfigurations, + metadataConfiguration, + privateEndpoint, + }; + if (connectorOnly && !GatewayClient.isConnectorTarget(request.targetConfiguration)) { + throw new InputValidationError( + "Connector updates require an MCP or inference connector Target configuration", + ); + } + const gateway = await this.getGateway(patch.gatewayId, options); + if (!gateway.roleArn) { + throw new Error(`Gateway ${patch.gatewayId} returned no execution role ARN.`); + } + const management = ExecutionRoleManager.policyManagement({ + associatedRoleArn: gateway.roleArn, + skipPolicyUpdate: patch.skipRolePolicyUpdate, + }); + if (management.mode === "external") { + return (await this.updateGatewayTargetAndWait(request, options)).response; + } + + const currentState = await this.readGatewayPolicyState(patch.gatewayId, options, gateway); + const targetIndex = currentState.targets.findIndex( + (target) => target.targetId === patch.targetId, + ); + if (targetIndex < 0) { + throw new Error(`Gateway ${patch.gatewayId} inventory is missing Target ${patch.targetId}.`); + } + const desiredTargets = [...currentState.targets]; + desiredTargets[targetIndex] = { + ...current, + name: request.name, + targetConfiguration: request.targetConfiguration, + credentialProviderConfigurations: request.credentialProviderConfigurations, + }; + const desiredCredentialProviders = await this.resolveCredentialProviderPolicyState( + desiredTargets, + options, + ); + const desired = this.planGatewayPolicy(currentState, { + credentialProviders: desiredCredentialProviders, + targets: desiredTargets, + }); + const result = await this.reconcileManagedPolicy({ + gatewayId: patch.gatewayId, + management, + currentState, + desired, + operation: () => this.updateGatewayTargetAndWait(request, options), + retryPropagation: true, + options, + }); + return result.response; + } + + private static replace( + current: T | undefined, + replacement: T | null | undefined, + ): T | undefined { + if (replacement === undefined) return current; + return replacement === null ? undefined : replacement; + } + + private static required(value: T | undefined, resource: string, field: string): T { + if (value === undefined) { + throw new AgentCoreCLIError(`${resource} is missing its ${field} required for update`, { + source: ERROR_SOURCE.SERVICE, + }); + } + return value; + } + private static isConnectorTarget(configuration: TargetConfiguration | undefined): boolean { return ( configuration?.mcp?.connector !== undefined || @@ -525,6 +869,30 @@ export class GatewayClient implements CoreGatewayClient { throw new GatewayOutcomeUnknownError(`Gateway ${gatewayId}`); } + private async waitForGatewayDeletion(gatewayId: string, options: CoreOptions): Promise { + const attempts = this.options.waitAttempts ?? DEFAULT_WAIT_ATTEMPTS; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const response = await this.getGateway(gatewayId, options); + if (response.status === "FAILED" || response.status === "UPDATE_UNSUCCESSFUL") { + throw new GatewayTerminalStateError( + gatewayId, + response.status, + response.statusReasons ?? [], + ); + } + } catch (error) { + if ((error as Error).name === "ResourceNotFoundException") return; + if (error instanceof GatewayTerminalStateError) throw error; + throw new GatewayOutcomeUnknownError(`Gateway ${gatewayId}`, { cause: error }); + } + 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, @@ -549,6 +917,97 @@ export class GatewayClient implements CoreGatewayClient { }; } + private async updateGatewayAndWait( + input: UpdateGatewayCommand["input"], + options: CoreOptions, + ): Promise<{ response: UpdateGatewayResponse; settled: GetGatewayResponse }> { + const control = this.clients.control(toClientConfig(options)); + let response: UpdateGatewayResponse; + try { + response = await control.send(new UpdateGatewayCommand(input)); + } catch (error) { + if (isExecutionRolePropagationError(error)) throw error; + if (isAmbiguousMutationError(error)) { + throw new GatewayOutcomeUnknownError(`Gateway ${input.gatewayIdentifier}`, { + cause: error, + }); + } + throw error; + } + return { + response, + settled: await this.waitForGateway(input.gatewayIdentifier!, options), + }; + } + + private async deleteGatewayAndWait( + input: DeleteGatewayCommand["input"], + options: CoreOptions, + ): Promise<{ response: DeleteGatewayResponse }> { + const control = this.clients.control(toClientConfig(options)); + let response: DeleteGatewayResponse; + try { + response = await control.send(new DeleteGatewayCommand(input)); + } catch (error) { + if (isAmbiguousMutationError(error)) { + throw new GatewayOutcomeUnknownError(`Gateway ${input.gatewayIdentifier}`, { + cause: error, + }); + } + throw error; + } + await this.waitForGatewayDeletion(input.gatewayIdentifier!, options); + return { response }; + } + + private async updateGatewayTargetAndWait( + input: UpdateGatewayTargetCommand["input"], + options: CoreOptions, + ): Promise<{ + response: UpdateGatewayTargetResponse; + settled: GetGatewayTargetResponse; + }> { + const control = this.clients.control(toClientConfig(options)); + let response: UpdateGatewayTargetResponse; + try { + response = await control.send(new UpdateGatewayTargetCommand(input)); + } catch (error) { + if (isExecutionRolePropagationError(error)) throw error; + if (isAmbiguousMutationError(error)) { + throw new GatewayOutcomeUnknownError( + `Gateway Target ${input.targetId} under ${input.gatewayIdentifier}`, + { cause: error }, + ); + } + throw error; + } + return { + response, + settled: await this.waitForGatewayTarget(input.gatewayIdentifier!, input.targetId!, options), + }; + } + + private async deleteGatewayTargetAndWait( + input: DeleteGatewayTargetCommand["input"], + options: CoreOptions, + ): Promise<{ response: DeleteGatewayTargetResponse }> { + const control = this.clients.control(toClientConfig(options)); + let response: DeleteGatewayTargetResponse; + try { + response = await control.send(new DeleteGatewayTargetCommand(input)); + } catch (error) { + if (isAmbiguousMutationError(error)) { + throw new GatewayOutcomeUnknownError( + `Gateway Target ${input.targetId} under ${input.gatewayIdentifier}`, + { cause: error }, + ); + } + throw error; + } + await this.waitForGatewayTargetDeletion(input.gatewayIdentifier!, input.targetId!, options); + return { response }; + } + private async observeGatewayByName( name: string, roleArn: string, @@ -638,14 +1097,138 @@ export class GatewayClient implements CoreGatewayClient { throw new GatewayOutcomeUnknownError(`Gateway Target ${targetId} under ${gatewayId}`); } + private async waitForGatewayTargetDeletion( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise { + const attempts = this.options.waitAttempts ?? DEFAULT_WAIT_ATTEMPTS; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const response = await this.getGatewayTarget(gatewayId, targetId, options); + if ( + response.status === "FAILED" || + response.status === "UPDATE_UNSUCCESSFUL" || + response.status === "SYNCHRONIZE_UNSUCCESSFUL" + ) { + throw new GatewayTargetTerminalStateError( + gatewayId, + targetId, + response.status, + response.statusReasons ?? [], + ); + } + } catch (error) { + if ((error as Error).name === "ResourceNotFoundException") return; + if (error instanceof GatewayTargetTerminalStateError) throw error; + throw new GatewayOutcomeUnknownError(`Gateway Target ${targetId} under ${gatewayId}`, { + cause: error, + }); + } + if (attempt < attempts) { + await this.sleep(this.options.waitDelayMs ?? DEFAULT_WAIT_DELAY_MS); + } + } + throw new GatewayOutcomeUnknownError(`Gateway Target ${targetId} under ${gatewayId}`); + } + + private planGatewayPolicy( + inventory: GatewayPolicyInventory, + overrides: Partial<{ + policyEngineConfiguration: GetGatewayResponse["policyEngineConfiguration"]; + interceptorConfigurations: GetGatewayResponse["interceptorConfigurations"]; + customTransformConfiguration: GetGatewayResponse["customTransformConfiguration"]; + targets: readonly { + targetId?: string; + name?: string; + targetConfiguration?: TargetConfiguration; + credentialProviderConfigurations?: readonly CredentialProviderConfiguration[]; + }[]; + credentialProviders: readonly GatewayCredentialProviderPolicyState[]; + }> = {}, + ): PolicyContribution[] { + return this.planner.plan({ + gatewayArn: inventory.gateway.gatewayArn, + workloadIdentityArn: inventory.gateway.workloadIdentityDetails?.workloadIdentityArn, + policyEngineConfiguration: + "policyEngineConfiguration" in overrides + ? overrides.policyEngineConfiguration + : inventory.gateway.policyEngineConfiguration, + interceptorConfigurations: + "interceptorConfigurations" in overrides + ? overrides.interceptorConfigurations + : inventory.gateway.interceptorConfigurations, + customTransformConfiguration: + "customTransformConfiguration" in overrides + ? overrides.customTransformConfiguration + : inventory.gateway.customTransformConfiguration, + targets: overrides.targets ?? inventory.targets, + credentialProviders: overrides.credentialProviders ?? inventory.credentialProviders, + }); + } + + private async reconcileManagedPolicy(input: { + gatewayId: string; + management: ManagedGatewayPolicy; + currentState: GatewayPolicyInventory; + desired: readonly PolicyContribution[]; + operation: () => Promise; + retryPropagation?: boolean; + options: CoreOptions; + }): Promise { + const iam = this.clients.iam({ region: input.options.region }); + const roleManager = new ExecutionRoleManager(iam, this.options.roleManager); + const gatewayArn = GatewayClient.required( + input.currentState.gateway.gatewayArn, + `Gateway "${input.gatewayId}"`, + "ARN", + ); + await roleManager.validateAgentCoreTrust(input.management.roleName, { + sourceAccount: accountIdFromRoleArn(input.management.roleArn), + sourceArn: gatewayArn, + }); + const policyName = gatewayPolicyName( + input.management.roleName, + input.management.roleArn, + input.currentState.gateway.gatewayId ?? input.gatewayId, + input.options.region, + ); + const policyUpdater = new ExecutionRolePolicyUpdater(iam, { + propagationDelayMs: 10_000, + ...this.options.policyUpdater, + }); + const result = await policyUpdater.update({ + roleName: input.management.roleName, + policyName, + current: this.planGatewayPolicy(input.currentState), + desired: input.desired, + operation: input.operation, + ...(input.retryPropagation + ? { + operationRetry: { + maxAttempts: 8, + delayMs: 2_000, + shouldRetry: isExecutionRolePropagationError, + }, + } + : {}), + isOperationOutcomeUnknown: (error) => error instanceof GatewayOutcomeUnknownError, + resolveDesired: async () => { + const settledState = await this.readGatewayPolicyState(input.gatewayId, input.options); + return { + contributions: this.planGatewayPolicy(settledState), + inventoryComplete: true, + }; + }, + }); + return result.value; + } + private async readGatewayPolicyState( gatewayId: string, options: CoreOptions, knownGateway?: GetGatewayResponse, - ): Promise<{ - gateway: GetGatewayResponse; - targets: GetGatewayTargetResponse[]; - }> { + ): Promise { const gateway = knownGateway ?? (await this.getGateway(gatewayId, options)); const targets: GetGatewayTargetResponse[] = []; const seenTokens = new Set(); @@ -665,8 +1248,86 @@ export class GatewayClient implements CoreGatewayClient { nextToken = page.nextToken; } while (nextToken); - return { gateway, targets }; + return { + gateway, + targets, + credentialProviders: await this.resolveCredentialProviderPolicyState(targets, options), + }; + } + + private async resolveCredentialProviderPolicyState( + targets: readonly { + credentialProviderConfigurations?: readonly CredentialProviderConfiguration[]; + }[], + options: CoreOptions, + ): Promise { + const providerKinds = new Map(); + for (const target of targets) { + for (const configuration of target.credentialProviderConfigurations ?? []) { + const providerArn = + configuration.credentialProvider?.apiKeyCredentialProvider?.providerArn ?? + configuration.credentialProvider?.oauthCredentialProvider?.providerArn; + const kind = + configuration.credentialProviderType === "API_KEY" + ? "api-key" + : configuration.credentialProviderType === "OAUTH" + ? "oauth" + : undefined; + if (!kind) continue; + if (!providerArn) { + throw new Error( + `${configuration.credentialProviderType} credential provider ARN is missing.`, + ); + } + const existingKind = providerKinds.get(providerArn); + if (existingKind && existingKind !== kind) { + throw new Error(`Credential provider ${providerArn} is used as two provider types.`); + } + providerKinds.set(providerArn, kind); + } + } + + const control = this.clients.control(toClientConfig(options)); + const providers: GatewayCredentialProviderPolicyState[] = []; + for (const [providerArn, kind] of [...providerKinds].sort(([left], [right]) => + left.localeCompare(right), + )) { + const name = credentialProviderName(providerArn, kind); + if (kind === "api-key") { + const response = await control.send(new GetApiKeyCredentialProviderCommand({ name })); + if (response.credentialProviderArn !== providerArn) { + throw new Error(`API key credential provider ${name} returned an unexpected ARN.`); + } + const secretArn = response.apiKeySecretArn?.secretArn; + if (!secretArn) { + throw new Error(`API key credential provider ${providerArn} returned no secret ARN.`); + } + providers.push({ providerArn, secretArn }); + continue; + } + + const response = await control.send(new GetOauth2CredentialProviderCommand({ name })); + if (response.credentialProviderArn !== providerArn) { + throw new Error(`OAuth credential provider ${name} returned an unexpected ARN.`); + } + const secretArn = response.clientSecretArn?.secretArn; + if (!secretArn) { + throw new Error(`OAuth credential provider ${providerArn} returned no secret ARN.`); + } + providers.push({ providerArn, secretArn }); + } + return providers; + } +} + +function credentialProviderName(providerArn: string, kind: "api-key" | "oauth"): string { + const resource = providerArn.split(":").slice(5).join(":"); + const expectedType = kind === "api-key" ? "apikeycredentialprovider" : "oauth2credentialprovider"; + const match = resource.match(new RegExp(`^token-vault/[^/]+/${expectedType}/([^/]+)$`)); + if (!match?.[1]) { + throw new Error(`Invalid ${kind} credential provider ARN "${providerArn}".`); } + return match[1]; } function accountIdFromRoleArn(roleArn: string): string { @@ -697,10 +1358,36 @@ function isAmbiguousCreateError(error: unknown): boolean { ); } +function isAmbiguousMutationError(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 gatewayPolicyName( + roleName: string, + roleArn: string, + gatewayId: string, + region: string, +): string { + return ExecutionRoleManager.generatedPolicyName("gateway", { + accountId: accountIdFromRoleArn(roleArn), + region, + stableResourceKey: stableGatewayPolicyKey(roleName, gatewayId), + }); +} + function isPendingAuthorizationStatus(status: string | undefined): boolean { return ( status === "CREATE_PENDING_AUTH" || diff --git a/src/core/gatewayIam.test.ts b/src/core/gatewayIam.test.ts index fc50c6171..a6e7d9fa7 100644 --- a/src/core/gatewayIam.test.ts +++ b/src/core/gatewayIam.test.ts @@ -2,10 +2,13 @@ import { describe, expect, test } from "bun:test"; import { CreateGatewayCommand, GetGatewayCommand, + ListGatewayTargetsCommand, ListGatewaysCommand, + UpdateGatewayCommand, type BedrockAgentCoreControlClient, type CreateGatewayResponse, type GetGatewayResponse, + type UpdateGatewayResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { CreateRoleCommand, @@ -28,6 +31,8 @@ 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"; +const OLD_POLICY_ENGINE_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders-old"; type IamCommand = | CreateRoleCommand @@ -630,6 +635,152 @@ describe("GatewayClient managed execution role", () => { `s3:GetObject ${existingResource}`, ); }); + + test("stages current and desired permissions around a Gateway update", async () => { + const policyName = ExecutionRoleManager.generatedPolicyName("gateway", { + accountId: ACCOUNT_ID, + region: REGION, + stableResourceKey: ROLE_NAME, + }); + const policies = new Map([ + [ + policyName, + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["bedrock-agentcore:InvokeGateway"], + Resource: [GATEWAY_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetPolicyEngine"], + Resource: [OLD_POLICY_ENGINE_ARN], + }, + { + Effect: "Allow", + Action: [ + "bedrock-agentcore:AuthorizeAction", + "bedrock-agentcore:PartiallyAuthorizeActions", + ], + Resource: [GATEWAY_ARN, OLD_POLICY_ENGINE_ARN], + }, + ], + }), + ], + ]); + const events: string[] = []; + const iam = { + send: async (command: IamCommand) => { + events.push(command.constructor.name); + 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 ${command.constructor.name}`); + }, + } as unknown as IAMClient; + const current: 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", + policyEngineConfiguration: { arn: OLD_POLICY_ENGINE_ARN, mode: "ENFORCE" }, + }; + const ready: GetGatewayResponse = { + ...current, + updatedAt: new Date("2026-08-12T00:01:00Z"), + policyEngineConfiguration: { arn: POLICY_ENGINE_ARN, mode: "ENFORCE" }, + }; + const updating: UpdateGatewayResponse = { + ...ready, + status: "UPDATING", + }; + let updated = false; + const control = { + send: async ( + command: GetGatewayCommand | ListGatewayTargetsCommand | UpdateGatewayCommand, + ) => { + events.push(command.constructor.name); + if (command instanceof GetGatewayCommand) return updated ? ready : current; + if (command instanceof ListGatewayTargetsCommand) return { items: [] }; + updated = true; + expect(policyPermissions(policies.get(policyName)!)).toEqual([ + `bedrock-agentcore:AuthorizeAction ${GATEWAY_ARN}`, + `bedrock-agentcore:AuthorizeAction ${POLICY_ENGINE_ARN}`, + `bedrock-agentcore:AuthorizeAction ${OLD_POLICY_ENGINE_ARN}`, + `bedrock-agentcore:GetPolicyEngine ${POLICY_ENGINE_ARN}`, + `bedrock-agentcore:GetPolicyEngine ${OLD_POLICY_ENGINE_ARN}`, + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `bedrock-agentcore:PartiallyAuthorizeActions ${GATEWAY_ARN}`, + `bedrock-agentcore:PartiallyAuthorizeActions ${POLICY_ENGINE_ARN}`, + `bedrock-agentcore:PartiallyAuthorizeActions ${OLD_POLICY_ENGINE_ARN}`, + ]); + return updating; + }, + } 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.updateGateway( + { + id: "orders-abc123", + policyEngineConfiguration: { arn: POLICY_ENGINE_ARN, mode: "ENFORCE" }, + }, + { region: REGION }, + ), + ).resolves.toEqual(updating); + + expect(events.indexOf("PutRolePolicyCommand")).toBeLessThan( + events.indexOf("UpdateGatewayCommand"), + ); + expect(policyPermissions(policies.get(policyName)!)).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}`, + ]); + }); }); describe("GatewayClient customer-managed execution role", () => { diff --git a/src/core/gatewayMutationIam.test.ts b/src/core/gatewayMutationIam.test.ts new file mode 100644 index 000000000..51ba00ba0 --- /dev/null +++ b/src/core/gatewayMutationIam.test.ts @@ -0,0 +1,628 @@ +import { describe, expect, test } from "bun:test"; +import { + DeleteGatewayCommand, + DeleteGatewayTargetCommand, + GetGatewayCommand, + GetGatewayTargetCommand, + ListGatewayTargetsCommand, + UpdateGatewayCommand, + UpdateGatewayTargetCommand, + type BedrockAgentCoreControlClient, + type GetGatewayResponse, + type GetGatewayTargetResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + DeleteRolePolicyCommand, + GetRoleCommand, + GetRolePolicyCommand, + ListRolePoliciesCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import { GatewayClient, GatewayTargetTerminalStateError } from "./gateway"; +import { ExecutionRoleManager } from "./executionRoleManager"; +import { PolicyOperationOutcomeUnknownError } from "./executionRolePolicyUpdater"; +import type { AwsClients } from "./types"; + +const REGION = "us-west-2"; +const ACCOUNT_ID = "123456789012"; +const GATEWAY_ID = "orders-abc123"; +const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders-abc123"; +const ROLE_NAME = "AgentCoreCliGateway-orders"; +const ROLE_ARN = `arn:aws:iam::${ACCOUNT_ID}:role/${ROLE_NAME}`; +const TARGET_ID = "lambda-target"; +const LAMBDA_A = "arn:aws:lambda:us-west-2:123456789012:function:orders-a"; +const LAMBDA_B = "arn:aws:lambda:us-west-2:123456789012:function:orders-b"; +const CUSTOMER_ROLE_ARN = `arn:aws:iam::${ACCOUNT_ID}:role/CustomerGatewayRole`; +const POLICY_NAME = ExecutionRoleManager.generatedPolicyName("gateway", { + accountId: ACCOUNT_ID, + region: REGION, + stableResourceKey: ROLE_NAME, +}); + +type IamCommand = + | DeleteRolePolicyCommand + | GetRoleCommand + | GetRolePolicyCommand + | ListRolePoliciesCommand + | PutRolePolicyCommand; + +function policyPermissions(document: string): string[] { + const parsed = JSON.parse(document) as { + Statement: { Action: string | string[]; Resource: string | string[] }[]; + }; + return parsed.Statement.flatMap((statement) => { + const actions = Array.isArray(statement.Action) ? statement.Action : [statement.Action]; + const resources = Array.isArray(statement.Resource) ? statement.Resource : [statement.Resource]; + return actions.flatMap((action) => resources.map((resource) => `${action} ${resource}`)); + }).sort(); +} + +function lambdaTarget( + lambdaArn: string, + status = "READY", + targetId = TARGET_ID, +): GetGatewayTargetResponse { + return { + gatewayArn: GATEWAY_ARN, + targetId, + createdAt: new Date("2026-08-12T00:00:00Z"), + updatedAt: new Date("2026-08-12T00:00:00Z"), + status: status as GetGatewayTargetResponse["status"], + name: "orders", + targetConfiguration: { + mcp: { + lambda: { + lambdaArn, + toolSchema: { inlinePayload: [] }, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }; +} + +function managedIam(policies: Map): IAMClient { + return { + 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) { + const policy = policies.get(command.input.PolicyName!); + if (!policy) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { PolicyDocument: policy }; + } + if (command instanceof PutRolePolicyCommand) { + policies.set(command.input.PolicyName!, command.input.PolicyDocument!); + return {}; + } + if (command instanceof DeleteRolePolicyCommand) { + policies.delete(command.input.PolicyName!); + return {}; + } + throw new Error("unexpected IAM command"); + }, + } as unknown as IAMClient; +} + +describe("GatewayClient managed Target IAM reconciliation", () => { + test("stages old and new Lambda permissions and tightens after Target update", async () => { + const policies = new Map([ + [ + POLICY_NAME, + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeGateway", + Resource: GATEWAY_ARN, + }, + { + Effect: "Allow", + Action: "lambda:InvokeFunction", + Resource: LAMBDA_A, + }, + ], + }), + ], + ]); + 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", + }; + let target = lambdaTarget(LAMBDA_A); + let updateObserved = false; + let failNextUpdate = false; + let timeoutNextUpdate = false; + const control = { + send: async ( + command: + | GetGatewayCommand + | GetGatewayTargetCommand + | ListGatewayTargetsCommand + | UpdateGatewayTargetCommand, + ) => { + if (command instanceof GetGatewayCommand) return gateway; + if (command instanceof ListGatewayTargetsCommand) { + return { items: [{ targetId: TARGET_ID, name: "orders", status: target.status }] }; + } + if (command instanceof GetGatewayTargetCommand) { + if (updateObserved && target.status === "UPDATING") { + target = lambdaTarget(LAMBDA_B); + } + return target; + } + + expect(policyPermissions(policies.get(POLICY_NAME)!)).toEqual([ + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `lambda:InvokeFunction ${LAMBDA_A}`, + `lambda:InvokeFunction ${LAMBDA_B}`, + ]); + updateObserved = true; + if (timeoutNextUpdate) { + const error = new Error("update response timed out"); + error.name = "TimeoutError"; + throw error; + } + if (failNextUpdate) { + target = { + ...lambdaTarget(LAMBDA_A, "UPDATE_UNSUCCESSFUL"), + statusReasons: ["target rejected replacement"], + }; + return lambdaTarget(LAMBDA_A, "UPDATING"); + } + target = lambdaTarget(LAMBDA_B, "UPDATING"); + return target; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => managedIam(policies), + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + waitDelayMs: 0, + }, + ); + + await expect( + client.updateGatewayTarget( + { + gatewayId: GATEWAY_ID, + targetId: TARGET_ID, + targetConfiguration: lambdaTarget(LAMBDA_B).targetConfiguration, + }, + { region: REGION }, + ), + ).resolves.toMatchObject({ status: "UPDATING" }); + + expect(updateObserved).toBeTrue(); + expect(policyPermissions(policies.get(POLICY_NAME)!)).toEqual([ + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `lambda:InvokeFunction ${LAMBDA_B}`, + ]); + + failNextUpdate = true; + await expect( + client.updateGatewayTarget( + { + gatewayId: GATEWAY_ID, + targetId: TARGET_ID, + targetConfiguration: lambdaTarget(LAMBDA_A).targetConfiguration, + }, + { region: REGION }, + ), + ).rejects.toBeInstanceOf(GatewayTargetTerminalStateError); + expect(policyPermissions(policies.get(POLICY_NAME)!)).toEqual([ + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `lambda:InvokeFunction ${LAMBDA_B}`, + ]); + + failNextUpdate = false; + timeoutNextUpdate = true; + target = lambdaTarget(LAMBDA_B); + const error = await client + .updateGatewayTarget( + { + gatewayId: GATEWAY_ID, + targetId: TARGET_ID, + targetConfiguration: lambdaTarget(LAMBDA_A).targetConfiguration, + }, + { region: REGION }, + ) + .catch((caught) => caught); + expect(error).toBeInstanceOf(PolicyOperationOutcomeUnknownError); + expect(policyPermissions(policies.get(POLICY_NAME)!)).toEqual([ + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `lambda:InvokeFunction ${LAMBDA_A}`, + `lambda:InvokeFunction ${LAMBDA_B}`, + ]); + }); + + test("removes a shared Target grant only after its final owner is deleted", async () => { + const policies = new Map([ + [ + POLICY_NAME, + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeGateway", + Resource: GATEWAY_ARN, + }, + { + Effect: "Allow", + Action: "lambda:InvokeFunction", + Resource: LAMBDA_A, + }, + ], + }), + ], + ]); + 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 targets = new Map([ + ["target-a", lambdaTarget(LAMBDA_A, "READY", "target-a")], + ["target-b", lambdaTarget(LAMBDA_A, "READY", "target-b")], + ]); + const deleting = new Set(); + const control = { + send: async ( + command: + | DeleteGatewayTargetCommand + | GetGatewayCommand + | GetGatewayTargetCommand + | ListGatewayTargetsCommand, + ) => { + if (command instanceof GetGatewayCommand) return gateway; + if (command instanceof ListGatewayTargetsCommand) { + return { + items: [...targets.values()].map((target) => ({ + targetId: target.targetId, + name: target.name, + status: target.status, + })), + }; + } + if (command instanceof GetGatewayTargetCommand) { + const targetId = command.input.targetId!; + if (deleting.delete(targetId)) targets.delete(targetId); + const target = targets.get(targetId); + if (!target) { + const error = new Error("missing"); + error.name = "ResourceNotFoundException"; + throw error; + } + return target; + } + + expect(policyPermissions(policies.get(POLICY_NAME)!)).toContain( + `lambda:InvokeFunction ${LAMBDA_A}`, + ); + deleting.add(command.input.targetId!); + return { + gatewayArn: GATEWAY_ARN, + targetId: command.input.targetId, + status: "DELETING", + }; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => managedIam(policies), + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + waitDelayMs: 0, + }, + ); + + await client.deleteGatewayTarget(GATEWAY_ID, "target-a", { region: REGION }); + expect(policyPermissions(policies.get(POLICY_NAME)!)).toEqual([ + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `lambda:InvokeFunction ${LAMBDA_A}`, + ]); + + await client.deleteGatewayTarget(GATEWAY_ID, "target-b", { region: REGION }); + expect(policyPermissions(policies.get(POLICY_NAME)!)).toEqual([ + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + ]); + }); + + test("removes only the generated policy after a Gateway is deleted", async () => { + const policies = new Map([ + [ + POLICY_NAME, + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeGateway", + Resource: GATEWAY_ARN, + }, + ], + }), + ], + [ + "CustomerPolicy", + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::customer/*", + }, + ], + }), + ], + ]); + 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", + }; + let deleting = false; + const control = { + send: async (command: DeleteGatewayCommand | GetGatewayCommand) => { + if (command instanceof DeleteGatewayCommand) { + expect(policies.has(POLICY_NAME)).toBeTrue(); + deleting = true; + return { gatewayId: GATEWAY_ID, status: "DELETING" }; + } + if (deleting) { + const error = new Error("missing"); + error.name = "ResourceNotFoundException"; + throw error; + } + return gateway; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => managedIam(policies), + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + waitDelayMs: 0, + }, + ); + + await expect(client.deleteGateway(GATEWAY_ID, { region: REGION })).resolves.toEqual({ + gatewayId: GATEWAY_ID, + status: "DELETING", + }); + expect(policies.has(POLICY_NAME)).toBeFalse(); + expect(policies.has("CustomerPolicy")).toBeTrue(); + }); + + test("does not mutate IAM or AgentCore when Target inventory is incomplete", async () => { + 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 currentTarget = lambdaTarget(LAMBDA_A); + let updateCalled = false; + const control = { + send: async ( + command: + | GetGatewayCommand + | GetGatewayTargetCommand + | ListGatewayTargetsCommand + | UpdateGatewayTargetCommand, + ) => { + if (command instanceof GetGatewayCommand) return gateway; + if (command instanceof GetGatewayTargetCommand) return currentTarget; + if (command instanceof ListGatewayTargetsCommand) { + return { + items: [{ targetId: TARGET_ID, name: "orders", status: "READY" }], + nextToken: "repeated", + }; + } + updateCalled = true; + return currentTarget; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => { + throw new Error("IAM must not be requested for incomplete inventory"); + }, + } as unknown as AwsClients, + { waitDelayMs: 0 }, + ); + + await expect( + client.updateGatewayTarget( + { + gatewayId: GATEWAY_ID, + targetId: TARGET_ID, + targetConfiguration: lambdaTarget(LAMBDA_B).targetConfiguration, + }, + { region: REGION }, + ), + ).rejects.toThrow(/repeated Target pagination token/); + expect(updateCalled).toBeFalse(); + }); + + test("skipRolePolicyUpdate bypasses IAM for a recognized role", async () => { + 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", + }; + let target = lambdaTarget(LAMBDA_A); + let updateCalled = false; + const control = { + send: async ( + command: GetGatewayCommand | GetGatewayTargetCommand | UpdateGatewayTargetCommand, + ) => { + if (command instanceof GetGatewayCommand) return gateway; + if (command instanceof GetGatewayTargetCommand) return target; + updateCalled = true; + target = lambdaTarget(LAMBDA_B); + return lambdaTarget(LAMBDA_B, "UPDATING"); + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => { + throw new Error("IAM must not be requested when role policy update is skipped"); + }, + } as unknown as AwsClients, + { waitDelayMs: 0 }, + ); + + await expect( + client.updateGatewayTarget( + { + gatewayId: GATEWAY_ID, + targetId: TARGET_ID, + targetConfiguration: lambdaTarget(LAMBDA_B).targetConfiguration, + skipRolePolicyUpdate: true, + }, + { region: REGION }, + ), + ).resolves.toMatchObject({ status: "UPDATING" }); + expect(updateCalled).toBeTrue(); + }); + + test("switching to an explicit role cleans only the old generated policy", async () => { + const policies = new Map([ + [ + POLICY_NAME, + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeGateway", + Resource: GATEWAY_ARN, + }, + ], + }), + ], + [ + "CustomerPolicy", + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::customer/*", + }, + ], + }), + ], + ]); + const current: 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 ready: GetGatewayResponse = { + ...current, + roleArn: CUSTOMER_ROLE_ARN, + description: "customer managed", + }; + let updated = false; + const control = { + send: async (command: GetGatewayCommand | UpdateGatewayCommand) => { + if (command instanceof GetGatewayCommand) return updated ? ready : current; + expect(policies.has(POLICY_NAME)).toBeTrue(); + updated = true; + return { ...ready, status: "UPDATING" }; + }, + } as unknown as BedrockAgentCoreControlClient; + const client = new GatewayClient( + { + control: () => control, + iam: () => managedIam(policies), + } as unknown as AwsClients, + { + policyUpdater: { propagationDelayMs: 0, retryDelayMs: 0 }, + waitDelayMs: 0, + }, + ); + + await expect( + client.updateGateway( + { + id: GATEWAY_ID, + roleArn: CUSTOMER_ROLE_ARN, + description: "customer managed", + }, + { region: REGION }, + ), + ).resolves.toMatchObject({ status: "UPDATING", roleArn: CUSTOMER_ROLE_ARN }); + expect(policies.has(POLICY_NAME)).toBeFalse(); + expect(policies.has("CustomerPolicy")).toBeTrue(); + }); +}); diff --git a/src/core/gatewayPolicy.test.ts b/src/core/gatewayPolicy.test.ts index c7b7f59ce..b33a03a96 100644 --- a/src/core/gatewayPolicy.test.ts +++ b/src/core/gatewayPolicy.test.ts @@ -9,8 +9,35 @@ import { 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 TRANSFORM_LAMBDA_ARN = "arn:aws:lambda:us-west-2:123456789012:function:orders-transform"; 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 RUNTIME_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders"; +const API_GATEWAY_ARN = "arn:aws:execute-api:us-west-2:123456789012:orders/prod/*/*"; +const KNOWLEDGE_BASE_ARN = "arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB12345678"; +const AGENTIC_KNOWLEDGE_BASE_ARN = + "arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB87654321"; +const MANTLE_PROJECT_ARN = "arn:aws:bedrock-mantle:us-west-2:123456789012:project/*"; +const MANTLE_DEFAULT_PROJECT_ARN = "arn:aws:bedrock-mantle:us-west-2:123456789012:project/default"; +const WORKLOAD_IDENTITY_DIRECTORY_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default"; +const WORKLOAD_IDENTITY_ARN = `${WORKLOAD_IDENTITY_DIRECTORY_ARN}/workload-identity/orders-abc123`; +const API_KEY_PROVIDER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; +const OAUTH_PROVIDER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/orders"; +const API_KEY_SECRET_ARN = + "arn:aws:secretsmanager:us-west-2:123456789012:secret:bedrock-agentcore-api-key"; +const OAUTH_SECRET_ARN = + "arn:aws:secretsmanager:us-west-2:123456789012:secret:bedrock-agentcore-oauth"; +const S3_OBJECT_ARNS = [ + "arn:aws:s3:::schemas/http.json", + "arn:aws:s3:::schemas/lambda.json", + "arn:aws:s3:::schemas/mcp.json", + "arn:aws:s3:::schemas/openapi.json", + "arn:aws:s3:::schemas/runtime.json", + "arn:aws:s3:::schemas/service.smithy", +]; describe("GatewayPolicyPlanner", () => { test("plans exact root, Policy Engine, Lambda, and Web Search permissions", () => { @@ -88,10 +115,9 @@ describe("GatewayPolicyPlanner", () => { ]); }); - test.each([ - [ - "Policy Engine interceptor", - { + test("plans exact Lambda permissions for interceptors and custom transforms", () => { + const compiled = new PolicyCompiler().compile( + new GatewayPolicyPlanner().plan({ gatewayArn: GATEWAY_ARN, interceptorConfigurations: [ { @@ -99,90 +125,294 @@ describe("GatewayPolicyPlanner", () => { interceptionPoints: ["REQUEST"], }, ], + customTransformConfiguration: { + lambda: { arn: TRANSFORM_LAMBDA_ARN }, + }, targets: [], - }, - ], - [ - "Knowledge Base connector", - { + }), + ); + + expect(compiled.permissions.map(({ action, resource }) => `${action} ${resource}`)).toEqual([ + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `lambda:InvokeFunction ${LAMBDA_ARN}`, + `lambda:InvokeFunction ${TRANSFORM_LAMBDA_ARN}`, + ]); + }); + + test("plans exact S3 object access for every schema-bearing Target shape", () => { + const compiled = new PolicyCompiler().compile( + new GatewayPolicyPlanner().plan({ gatewayArn: GATEWAY_ARN, targets: [ { - targetId: "kb", + targetId: "lambda", targetConfiguration: { mcp: { - connector: { - source: { connectorId: "bedrock-knowledge-bases" }, + lambda: { + lambdaArn: LAMBDA_ARN, + toolSchema: { s3: { uri: "s3://schemas/lambda.json" } }, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + { + targetId: "openapi", + targetConfiguration: { + mcp: { openApiSchema: { s3: { uri: "s3://schemas/openapi.json" } } }, + }, + }, + { + targetId: "smithy", + targetConfiguration: { + mcp: { smithyModel: { s3: { uri: "s3://schemas/service.smithy" } } }, + }, + }, + { + targetId: "mcp-server", + targetConfiguration: { + mcp: { + mcpServer: { + endpoint: "https://example.com/mcp", + mcpToolSchema: { s3: { uri: "s3://schemas/mcp.json" } }, }, }, }, + credentialProviderConfigurations: [{ credentialProviderType: "JWT_PASSTHROUGH" }], + }, + { + targetId: "runtime", + targetConfiguration: { + http: { + agentcoreRuntime: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders", + schema: { source: { s3: { uri: "s3://schemas/runtime.json" } } }, + }, + }, + }, + credentialProviderConfigurations: [ + { credentialProviderType: "CALLER_IAM_CREDENTIALS" }, + ], + }, + { + targetId: "passthrough", + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://example.com", + protocolType: "CUSTOM", + schema: { source: { s3: { uri: "s3://schemas/http.json" } } }, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "JWT_PASSTHROUGH" }], }, ], - }, - ], - [ - "API Gateway Target", - { + }), + ); + + expect(compiled.permissions.map(({ action, resource }) => `${action} ${resource}`)).toEqual([ + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `lambda:InvokeFunction ${LAMBDA_ARN}`, + ...S3_OBJECT_ARNS.map((arn) => `s3:GetObject ${arn}`), + ]); + }); + + test("plans API Gateway, Runtime, Knowledge Base, and Bedrock Mantle target access", () => { + const compiled = new PolicyCompiler().compile( + new GatewayPolicyPlanner().plan({ gatewayArn: GATEWAY_ARN, targets: [ { - targetId: "api", + targetId: "api-gateway", targetConfiguration: { mcp: { apiGateway: { - restApiId: "api-id", + restApiId: "orders", stage: "prod", apiGatewayToolConfiguration: { toolFilters: [] }, }, }, }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + { + targetId: "runtime", + targetConfiguration: { + http: { agentcoreRuntime: { arn: RUNTIME_ARN } }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + { + targetId: "knowledge-base", + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "bedrock-knowledge-bases" }, + configurations: [ + { + name: "AgenticRetrieveStream", + parameterValues: { + retrievers: [ + { + configuration: { + knowledgeBase: { knowledgeBaseId: "KB87654321" }, + }, + }, + ], + agenticRetrieveConfiguration: {}, + }, + }, + { + name: "Retrieve", + parameterValues: { knowledgeBaseId: "KB12345678" }, + }, + ], + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + { + targetId: "mantle", + targetConfiguration: { + inference: { connector: { source: { connectorId: "bedrock-mantle" } } }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], }, ], - }, - ], - [ - "AgentCore Runtime Target", - { + }), + ); + const permissions = compiled.permissions.map(({ action, resource }) => `${action} ${resource}`); + + expect(permissions).toEqual( + expect.arrayContaining([ + `bedrock-agentcore:InvokeAgentRuntime ${RUNTIME_ARN}`, + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + "bedrock-mantle:CallWithBearerToken *", + `bedrock-mantle:CreateInference ${MANTLE_PROJECT_ARN}`, + `bedrock-mantle:ListModels ${MANTLE_DEFAULT_PROJECT_ARN}`, + "bedrock:AgenticRetrieveStream *", + `bedrock:GetKnowledgeBase ${AGENTIC_KNOWLEDGE_BASE_ARN}`, + `bedrock:GetKnowledgeBase ${KNOWLEDGE_BASE_ARN}`, + `bedrock:Retrieve ${KNOWLEDGE_BASE_ARN}`, + `execute-api:Invoke ${API_GATEWAY_ARN}`, + ]), + ); + expect(permissions).toHaveLength(10); + }); + + test("plans exact workload, provider, and secret access for API key and OAuth auth", () => { + const compiled = new PolicyCompiler().compile( + new GatewayPolicyPlanner().plan({ gatewayArn: GATEWAY_ARN, + workloadIdentityArn: WORKLOAD_IDENTITY_ARN, + credentialProviders: [ + { providerArn: API_KEY_PROVIDER_ARN, secretArn: API_KEY_SECRET_ARN }, + { providerArn: OAUTH_PROVIDER_ARN, secretArn: OAUTH_SECRET_ARN }, + ], targets: [ { - targetId: "runtime", + targetId: "api-key", targetConfiguration: { - http: { - agentcoreRuntime: { - arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/runtime-id", + mcp: { mcpServer: { endpoint: "https://example.com/api-key" } }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { + providerArn: API_KEY_PROVIDER_ARN, + credentialLocation: "HEADER", + credentialParameterName: "x-api-key", + }, }, }, + ], + }, + { + targetId: "oauth", + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.com/oauth" } }, }, + credentialProviderConfigurations: [ + { + credentialProviderType: "OAUTH", + credentialProvider: { + oauthCredentialProvider: { + providerArn: OAUTH_PROVIDER_ARN, + scopes: ["orders.read"], + grantType: "CLIENT_CREDENTIALS", + }, + }, + }, + ], }, ], - }, - ], - [ - "S3 schema", - { + }), + ); + const permissions = compiled.permissions.map(({ action, resource }) => `${action} ${resource}`); + + expect(permissions).toEqual( + expect.arrayContaining([ + `bedrock-agentcore:GetResourceApiKey ${API_KEY_PROVIDER_ARN}`, + `bedrock-agentcore:GetResourceOauth2Token ${OAUTH_PROVIDER_ARN}`, + `bedrock-agentcore:GetWorkloadAccessToken ${WORKLOAD_IDENTITY_DIRECTORY_ARN}`, + `bedrock-agentcore:GetWorkloadAccessToken ${WORKLOAD_IDENTITY_ARN}`, + `bedrock-agentcore:InvokeGateway ${GATEWAY_ARN}`, + `secretsmanager:GetSecretValue ${API_KEY_SECRET_ARN}`, + `secretsmanager:GetSecretValue ${OAUTH_SECRET_ARN}`, + ]), + ); + expect(permissions).toHaveLength(7); + }); + + test("does not grant target access for caller IAM or JWT passthrough", () => { + const compiled = new PolicyCompiler().compile( + new GatewayPolicyPlanner().plan({ gatewayArn: GATEWAY_ARN, targets: [ { - targetId: "schema", + targetId: "caller-iam", targetConfiguration: { - mcp: { - openApiSchema: { - s3: { uri: "s3://bucket/schema.json" }, + http: { agentcoreRuntime: { arn: RUNTIME_ARN } }, + }, + credentialProviderConfigurations: [ + { credentialProviderType: "CALLER_IAM_CREDENTIALS" }, + ], + }, + { + targetId: "jwt", + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://example.com", + protocolType: "CUSTOM", }, }, }, + credentialProviderConfigurations: [{ credentialProviderType: "JWT_PASSTHROUGH" }], }, ], - }, - ], + }), + ); + + expect(compiled.permissions).toEqual([ + expect.objectContaining({ + action: "bedrock-agentcore:InvokeGateway", + resource: GATEWAY_ARN, + }), + ]); + }); + + test.each([ [ - "API key auth", + "generic SigV4 target", { gatewayArn: GATEWAY_ARN, targets: [ { - targetId: "api-key", + targetId: "sigv4", targetConfiguration: { mcp: { mcpServer: { @@ -192,7 +422,12 @@ describe("GatewayPolicyPlanner", () => { }, credentialProviderConfigurations: [ { - credentialProviderType: "API_KEY", + credentialProviderType: "GATEWAY_IAM_ROLE", + credentialProvider: { + iamCredentialProvider: { + service: "example", + }, + }, }, ], }, @@ -200,25 +435,30 @@ describe("GatewayPolicyPlanner", () => { }, ], [ - "HTTP S3 schema", + "generic SigV4 HTTP passthrough", { gatewayArn: GATEWAY_ARN, targets: [ { - targetId: "http-schema", + targetId: "sigv4-http", targetConfiguration: { http: { passthrough: { endpoint: "https://example.com", protocolType: "CUSTOM", - schema: { - source: { - s3: { uri: "s3://bucket/http-schema.json" }, - }, - }, }, }, }, + credentialProviderConfigurations: [ + { + credentialProviderType: "GATEWAY_IAM_ROLE", + credentialProvider: { + iamCredentialProvider: { + service: "example", + }, + }, + }, + ], }, ], }, diff --git a/src/core/gatewayPolicy.ts b/src/core/gatewayPolicy.ts index be5e3a8a2..c9b2e0e5f 100644 --- a/src/core/gatewayPolicy.ts +++ b/src/core/gatewayPolicy.ts @@ -17,12 +17,19 @@ export type GatewayTargetPolicyState = { export type GatewayPolicyState = { gatewayArn?: string; + workloadIdentityArn?: string; policyEngineConfiguration?: GatewayPolicyEngineConfiguration; interceptorConfigurations?: readonly GatewayInterceptorConfiguration[]; customTransformConfiguration?: CustomTransformConfiguration; + credentialProviders?: readonly GatewayCredentialProviderPolicyState[]; targets: readonly GatewayTargetPolicyState[]; }; +export type GatewayCredentialProviderPolicyState = { + providerArn: string; + secretArn: string; +}; + export class UninferrableGatewayPermissionError extends Error { constructor( readonly owner: string, @@ -37,17 +44,35 @@ 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", - ); - } + state.interceptorConfigurations?.forEach((configuration, index) => { + const owner = `gateway:interceptor:${index}`; + if (containsKey(configuration, "$unknown")) { + throw new UninferrableGatewayPermissionError( + owner, + "interceptor contains an unknown SDK union", + ); + } + const arn = configuration.interceptor?.lambda?.arn; + if (!arn) { + throw new UninferrableGatewayPermissionError(owner, "Lambda ARN is missing"); + } + contributions.push({ + owner, + reason: "invoke Gateway interceptor", + statements: [AgentCorePolicyGrants.invokeLambda(arn)], + }); + }); if (state.customTransformConfiguration) { - throw new UninferrableGatewayPermissionError( - "gateway:custom-transform", - "custom transform permissions are not implemented in this stack layer", - ); + const owner = "gateway:custom-transform"; + const arn = state.customTransformConfiguration.lambda?.arn; + if (!arn) { + throw new UninferrableGatewayPermissionError(owner, "Lambda ARN is missing"); + } + contributions.push({ + owner, + reason: "invoke Gateway custom transform", + statements: [AgentCorePolicyGrants.invokeLambda(arn)], + }); } if (state.gatewayArn) { contributions.push({ @@ -85,6 +110,7 @@ export class GatewayPolicyPlanner { if (containsKey(target.targetConfiguration, "$unknown")) { throw new UninferrableGatewayPermissionError(owner, "Target contains an unknown SDK union"); } + contributions.push(...this.planCredentialProviders(state, target, owner)); const lambda = target.targetConfiguration?.mcp?.lambda; if (lambda) { if (!lambda.lambdaArn) { @@ -98,12 +124,13 @@ export class GatewayPolicyPlanner { 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", - ); - } + this.addSchemaContribution( + contributions, + owner, + "read Lambda tool schema", + lambda.toolSchema, + state.gatewayArn, + ); return; } @@ -127,18 +154,124 @@ export class GatewayPolicyPlanner { return; } + if ( + target.targetConfiguration.mcp?.connector?.source?.connectorId === "bedrock-knowledge-bases" + ) { + this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ + "GATEWAY_IAM_ROLE", + ]); + const knowledgeBasePlan = knowledgeBasePermissions( + target.targetConfiguration.mcp.connector.configurations, + state.gatewayArn, + owner, + ); + contributions.push({ + owner, + reason: "use managed Knowledge Base connector", + statements: [ + AgentCorePolicyGrants.getKnowledgeBases(knowledgeBasePlan.allKnowledgeBaseArns), + ...(knowledgeBasePlan.retrieveKnowledgeBaseArns.length > 0 + ? [ + AgentCorePolicyGrants.retrieveKnowledgeBases( + knowledgeBasePlan.retrieveKnowledgeBaseArns, + ), + ] + : []), + ...(knowledgeBasePlan.agenticRetrieve + ? [AgentCorePolicyGrants.agenticRetrieveKnowledgeBases()] + : []), + ], + }); + return; + } + + if ( + target.targetConfiguration.inference?.connector?.source?.connectorId === "bedrock-mantle" + ) { + this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ + "GATEWAY_IAM_ROLE", + ]); + const context = gatewayArnContext(state.gatewayArn, owner); + contributions.push({ + owner, + reason: "invoke Bedrock Mantle connector", + statements: [ + AgentCorePolicyGrants.createMantleInference([ + `arn:${context.partition}:bedrock-mantle:${context.region}:${context.accountId}:project/*`, + ]), + AgentCorePolicyGrants.listMantleModels( + `arn:${context.partition}:bedrock-mantle:${context.region}:${context.accountId}:project/default`, + ), + AgentCorePolicyGrants.callMantleWithBearerToken(), + ], + }); + return; + } + + const inference = + target.targetConfiguration.inference?.connector ?? + target.targetConfiguration.inference?.provider; + if (inference) { + this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ + "API_KEY", + "OAUTH", + "GATEWAY_IAM_ROLE", + ]); + if ( + hasExternalCredential(target.credentialProviderConfigurations, "API_KEY", "OAUTH") || + (target.credentialProviderConfigurations?.length ?? 0) === 0 + ) { + return; + } + throw new UninferrableGatewayPermissionError( + owner, + "IAM permissions for this inference provider cannot be inferred", + ); + } + + const apiGateway = target.targetConfiguration.mcp?.apiGateway; + if (apiGateway) { + this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ + "GATEWAY_IAM_ROLE", + "API_KEY", + ]); + if (!hasExternalCredential(target.credentialProviderConfigurations, "API_KEY")) { + if (!apiGateway.restApiId || !apiGateway.stage) { + throw new UninferrableGatewayPermissionError( + owner, + "API Gateway REST API ID or stage is missing", + ); + } + const context = gatewayArnContext(state.gatewayArn, owner); + contributions.push({ + owner, + reason: "invoke API Gateway target", + statements: [ + AgentCorePolicyGrants.invokeApiGateway( + `arn:${context.partition}:execute-api:${context.region}:${context.accountId}:` + + `${apiGateway.restApiId}/${apiGateway.stage}/*/*`, + ), + ], + }); + } + return; + } + const mcpServer = target.targetConfiguration.mcp?.mcpServer; if (mcpServer) { this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ "CALLER_IAM_CREDENTIALS", "JWT_PASSTHROUGH", + "API_KEY", + "OAUTH", ]); - if (mcpServer.mcpToolSchema && "s3" in mcpServer.mcpToolSchema) { - throw new UninferrableGatewayPermissionError( - owner, - "S3 MCP tool schema permissions are not implemented in this stack layer", - ); - } + this.addSchemaContribution( + contributions, + owner, + "read MCP tool schema", + mcpServer.mcpToolSchema, + state.gatewayArn, + ); return; } @@ -149,27 +282,75 @@ export class GatewayPolicyPlanner { this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ "CALLER_IAM_CREDENTIALS", "JWT_PASSTHROUGH", + "API_KEY", + "OAUTH", ]); - if ("s3" in openApiSchema) { - throw new UninferrableGatewayPermissionError( + this.addSchemaContribution( + contributions, + owner, + "read API schema", + openApiSchema, + state.gatewayArn, + ); + return; + } + + const runtime = target.targetConfiguration.http?.agentcoreRuntime; + if (runtime) { + this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ + "GATEWAY_IAM_ROLE", + "CALLER_IAM_CREDENTIALS", + "JWT_PASSTHROUGH", + "OAUTH", + ]); + if (!runtime.arn) { + throw new UninferrableGatewayPermissionError(owner, "Runtime ARN is missing"); + } + if ( + !hasExternalCredential( + target.credentialProviderConfigurations, + "CALLER_IAM_CREDENTIALS", + "JWT_PASSTHROUGH", + "OAUTH", + ) + ) { + contributions.push({ owner, - "S3 schema permissions are not implemented in this stack layer", - ); + reason: "invoke AgentCore Runtime target", + statements: [AgentCorePolicyGrants.invokeRuntime([runtime.arn])], + }); } + this.addSchemaContribution( + contributions, + owner, + "read Runtime API schema", + runtime.schema?.source, + state.gatewayArn, + ); return; } if (target.targetConfiguration.http?.passthrough) { this.validateCredentialProviders(owner, target.credentialProviderConfigurations, [ + "GATEWAY_IAM_ROLE", "CALLER_IAM_CREDENTIALS", "JWT_PASSTHROUGH", + "API_KEY", + "OAUTH", ]); - if (containsKey(target.targetConfiguration.http.passthrough, "s3")) { + if (hasExternalCredential(target.credentialProviderConfigurations, "GATEWAY_IAM_ROLE")) { throw new UninferrableGatewayPermissionError( owner, - "S3 HTTP schema permissions are not implemented in this stack layer", + "IAM permissions for this HTTP endpoint cannot be inferred", ); } + this.addSchemaContribution( + contributions, + owner, + "read HTTP API schema", + target.targetConfiguration.http.passthrough.schema?.source, + state.gatewayArn, + ); return; } @@ -182,6 +363,92 @@ export class GatewayPolicyPlanner { return contributions; } + private planCredentialProviders( + state: GatewayPolicyState, + target: GatewayTargetPolicyState, + owner: string, + ): PolicyContribution[] { + const contributions: PolicyContribution[] = []; + const providers = new Map( + (state.credentialProviders ?? []).map((provider) => [provider.providerArn, provider]), + ); + + for (const configuration of target.credentialProviderConfigurations ?? []) { + if (containsKey(configuration, "$unknown")) { + throw new UninferrableGatewayPermissionError( + owner, + "credential provider contains an unknown SDK union", + ); + } + if ( + configuration.credentialProviderType === "GATEWAY_IAM_ROLE" || + configuration.credentialProviderType === "CALLER_IAM_CREDENTIALS" || + configuration.credentialProviderType === "JWT_PASSTHROUGH" + ) { + continue; + } + + const isApiKey = configuration.credentialProviderType === "API_KEY"; + const isOauth = configuration.credentialProviderType === "OAUTH"; + if (!isApiKey && !isOauth) { + throw new UninferrableGatewayPermissionError( + owner, + `credential provider ${configuration.credentialProviderType ?? "unknown"} is not supported`, + ); + } + const providerArn = isApiKey + ? configuration.credentialProvider?.apiKeyCredentialProvider?.providerArn + : configuration.credentialProvider?.oauthCredentialProvider?.providerArn; + if (!providerArn) { + throw new UninferrableGatewayPermissionError(owner, "credential provider ARN is missing"); + } + const provider = providers.get(providerArn); + if (!provider?.secretArn) { + throw new UninferrableGatewayPermissionError( + owner, + `resolved secret ARN for credential provider ${providerArn} is missing`, + ); + } + const workloadArns = workloadIdentityResources(state.workloadIdentityArn, owner); + contributions.push({ + owner: `${owner}:auth:${providerArn}`, + reason: isApiKey ? "retrieve API key credential" : "retrieve OAuth credential", + statements: [ + AgentCorePolicyGrants.getWorkloadAccessToken(workloadArns), + isApiKey + ? AgentCorePolicyGrants.getResourceApiKey(providerArn) + : AgentCorePolicyGrants.getResourceOauth2Token(providerArn), + AgentCorePolicyGrants.readSecret(provider.secretArn), + ], + }); + } + + return contributions; + } + + private addSchemaContribution( + contributions: PolicyContribution[], + owner: string, + reason: string, + schema: unknown, + gatewayArn: string | undefined, + ): void { + if (schema === undefined) return; + if (containsKey(schema, "$unknown")) { + throw new UninferrableGatewayPermissionError(owner, "schema contains an unknown SDK union"); + } + const uri = s3UriFromSchema(schema); + if (uri === undefined) return; + if (!gatewayArn) { + throw new UninferrableGatewayPermissionError(owner, "Gateway ARN is missing"); + } + contributions.push({ + owner: `${owner}:schema`, + reason, + statements: [AgentCorePolicyGrants.readS3Object(s3ObjectArn(uri, gatewayArn, owner))], + }); + } + private validateCredentialProviders( owner: string, configurations: readonly CredentialProviderConfiguration[] | undefined, @@ -198,6 +465,164 @@ export class GatewayPolicyPlanner { } } +function workloadIdentityResources( + workloadIdentityArn: string | undefined, + owner: string, +): [string, string] { + const separator = "/workload-identity/"; + const separatorIndex = workloadIdentityArn?.indexOf(separator) ?? -1; + if (!workloadIdentityArn || separatorIndex < 0) { + throw new UninferrableGatewayPermissionError(owner, "Gateway workload identity ARN is missing"); + } + return [workloadIdentityArn.slice(0, separatorIndex), workloadIdentityArn]; +} + +type GatewayArnContext = { + partition: string; + region: string; + accountId: string; +}; + +function gatewayArnContext(gatewayArn: string | undefined, owner: string): GatewayArnContext { + const [prefix, partition, service, region, accountId, resource] = gatewayArn?.split(":") ?? []; + if ( + prefix !== "arn" || + !partition || + service !== "bedrock-agentcore" || + !region || + !accountId || + !resource?.startsWith("gateway/") + ) { + throw new UninferrableGatewayPermissionError( + owner, + `invalid Gateway ARN "${gatewayArn ?? ""}"`, + ); + } + return { partition, region, accountId }; +} + +function hasExternalCredential( + configurations: readonly CredentialProviderConfiguration[] | undefined, + ...types: readonly string[] +): boolean { + return (configurations ?? []).some((configuration) => + types.includes(configuration.credentialProviderType ?? ""), + ); +} + +function knowledgeBasePermissions( + configurations: readonly { name?: string; parameterValues?: unknown }[] | undefined, + gatewayArn: string | undefined, + owner: string, +): { + allKnowledgeBaseArns: string[]; + retrieveKnowledgeBaseArns: string[]; + agenticRetrieve: boolean; +} { + if (!configurations || configurations.length === 0) { + throw new UninferrableGatewayPermissionError( + owner, + "Knowledge Base connector has no tool configurations", + ); + } + const context = gatewayArnContext(gatewayArn, owner); + const allKnowledgeBaseIds = new Set(); + const retrieveKnowledgeBaseIds = new Set(); + let agenticRetrieve = false; + + for (const configuration of configurations) { + if (configuration.name === "Retrieve") { + const knowledgeBaseId = nestedString(configuration.parameterValues, "knowledgeBaseId"); + if (!knowledgeBaseId) { + throw new UninferrableGatewayPermissionError( + owner, + "Retrieve configuration is missing knowledgeBaseId", + ); + } + allKnowledgeBaseIds.add(knowledgeBaseId); + retrieveKnowledgeBaseIds.add(knowledgeBaseId); + continue; + } + if (configuration.name === "AgenticRetrieveStream") { + const retrievers = nestedArray(configuration.parameterValues, "retrievers"); + if (!retrievers || retrievers.length === 0) { + throw new UninferrableGatewayPermissionError( + owner, + "AgenticRetrieveStream configuration has no retrievers", + ); + } + for (const retriever of retrievers) { + const knowledgeBaseId = nestedString( + retriever, + "configuration", + "knowledgeBase", + "knowledgeBaseId", + ); + if (!knowledgeBaseId) { + throw new UninferrableGatewayPermissionError( + owner, + "AgenticRetrieveStream retriever is missing knowledgeBaseId", + ); + } + allKnowledgeBaseIds.add(knowledgeBaseId); + } + agenticRetrieve = true; + continue; + } + throw new UninferrableGatewayPermissionError( + owner, + `Knowledge Base connector tool ${configuration.name ?? "unknown"} is not supported`, + ); + } + + const toArn = (knowledgeBaseId: string) => + `arn:${context.partition}:bedrock:${context.region}:${context.accountId}:knowledge-base/${knowledgeBaseId}`; + return { + allKnowledgeBaseArns: [...allKnowledgeBaseIds].sort().map(toArn), + retrieveKnowledgeBaseArns: [...retrieveKnowledgeBaseIds].sort().map(toArn), + agenticRetrieve, + }; +} + +function nestedString(value: unknown, ...path: readonly string[]): string | undefined { + let current = value; + for (const key of path) { + if (current === null || typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Record)[key]; + } + return typeof current === "string" && current.length > 0 ? current : undefined; +} + +function nestedArray(value: unknown, ...path: readonly string[]): unknown[] | undefined { + let current = value; + for (const key of path) { + if (current === null || typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Record)[key]; + } + return Array.isArray(current) ? current : undefined; +} + +function s3UriFromSchema(schema: unknown): string | undefined { + if (schema === null || typeof schema !== "object" || Array.isArray(schema)) return undefined; + const s3 = (schema as Record).s3; + if (s3 === undefined) return undefined; + if (s3 === null || typeof s3 !== "object" || Array.isArray(s3)) return ""; + const uri = (s3 as Record).uri; + return typeof uri === "string" ? uri : ""; +} + +function s3ObjectArn(uri: string, gatewayArn: string, owner: string): string { + const match = uri.match(/^s3:\/\/([^/]+)\/(.+)$/); + if (!match?.[1] || !match[2]) { + throw new UninferrableGatewayPermissionError(owner, `invalid S3 object URI "${uri}"`); + } + const [prefix, partition] = gatewayArn.split(":"); + if (prefix !== "arn" || !partition) { + throw new UninferrableGatewayPermissionError(owner, `invalid Gateway ARN "${gatewayArn}"`); + } + return `arn:${partition}:s3:::${match[1]}/${match[2]}`; +} + 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; diff --git a/src/core/gatewayTargetIam.test.ts b/src/core/gatewayTargetIam.test.ts index 72d2baa73..16db5f855 100644 --- a/src/core/gatewayTargetIam.test.ts +++ b/src/core/gatewayTargetIam.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { CreateGatewayTargetCommand, + GetApiKeyCredentialProviderCommand, GetGatewayCommand, GetGatewayTargetCommand, ListGatewayTargetsCommand, @@ -30,6 +31,12 @@ const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/or 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 WORKLOAD_IDENTITY_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default/workload-identity/orders-abc123"; +const API_KEY_PROVIDER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; +const API_KEY_SECRET_ARN = + "arn:aws:secretsmanager:us-west-2:123456789012:secret:bedrock-agentcore-orders"; const POLICY_NAME = ExecutionRoleManager.generatedPolicyName("gateway", { accountId: ACCOUNT_ID, region: REGION, @@ -51,7 +58,7 @@ function permissions(document: string): string[] { } describe("GatewayClient Target IAM reconciliation", () => { - test("stages and finalizes exact Lambda permission on a recognized role", async () => { + test("stages and finalizes exact target and credential permissions on a recognized role", async () => { const planner = new GatewayPolicyPlanner(); const policies = new Map([ [ @@ -102,6 +109,7 @@ describe("GatewayClient Target IAM reconciliation", () => { name: "orders", roleArn: ROLE_ARN, authorizerType: "NONE", + workloadIdentityDetails: { workloadIdentityArn: WORKLOAD_IDENTITY_ARN }, }; const lambdaTarget: GetGatewayTargetResponse = { gatewayArn: GATEWAY_ARN, @@ -136,47 +144,61 @@ describe("GatewayClient Target IAM reconciliation", () => { }, credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], }; + const apiKeyTarget: GetGatewayTargetResponse = { + gatewayArn: GATEWAY_ARN, + targetId: "api-key-target", + createdAt: new Date("2026-08-12T00:00:00Z"), + updatedAt: new Date("2026-08-12T00:00:00Z"), + status: "READY", + name: "api-key", + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.com/mcp" } }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { + providerArn: API_KEY_PROVIDER_ARN, + credentialLocation: "HEADER", + credentialParameterName: "x-api-key", + }, + }, + }, + ], + }; const targets: GetGatewayTargetResponse[] = []; const listedTokens: (string | undefined)[] = []; const control = { send: async ( command: | CreateGatewayTargetCommand + | GetApiKeyCredentialProviderCommand | GetGatewayCommand | GetGatewayTargetCommand | ListGatewayTargetsCommand, ) => { if (command instanceof GetGatewayCommand) return gateway; + if (command instanceof GetApiKeyCredentialProviderCommand) { + return { + name: "orders", + credentialProviderArn: API_KEY_PROVIDER_ARN, + apiKeySecretArn: { secretArn: API_KEY_SECRET_ARN }, + createdTime: new Date("2026-08-12T00:00:00Z"), + lastUpdatedTime: new Date("2026-08-12T00:00:00Z"), + }; + } 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", - }; - } + const page = command.input.nextToken + ? Number(command.input.nextToken.replace("page-", "")) - 1 + : 0; + const target = targets[page]; return { - items: targets.map((target) => ({ - targetId: target.targetId, - name: target.name, - status: target.status, - })), + items: target + ? [{ targetId: target.targetId, name: target.name, status: target.status }] + : [], + ...(page + 1 < targets.length ? { nextToken: `page-${page + 2}` } : {}), }; } if (command instanceof GetGatewayTargetCommand) { @@ -190,11 +212,22 @@ describe("GatewayClient Target IAM reconciliation", () => { 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" }; + if (command.input.targetConfiguration?.mcp?.connector) { + 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" }; + } + expect(staged).toContain(`bedrock-agentcore:GetResourceApiKey ${API_KEY_PROVIDER_ARN}`); + expect(staged).toContain( + `bedrock-agentcore:GetWorkloadAccessToken ${WORKLOAD_IDENTITY_ARN}`, + ); + expect(staged).toContain(`secretsmanager:GetSecretValue ${API_KEY_SECRET_ARN}`); + targets.push(apiKeyTarget); + return { ...apiKeyTarget, status: "CREATING" }; } throw new Error("unexpected control command"); }, @@ -251,6 +284,27 @@ describe("GatewayClient Target IAM reconciliation", () => { `lambda:InvokeFunction ${LAMBDA_ARN}`, ]); expect(listedTokens).toContain("page-2"); + + const apiKeyResult = await client.createGatewayTarget( + { + gatewayIdentifier: GATEWAY_ID, + name: "api-key", + targetConfiguration: apiKeyTarget.targetConfiguration!, + credentialProviderConfigurations: apiKeyTarget.credentialProviderConfigurations, + }, + { region: REGION }, + ); + + expect(apiKeyResult).toEqual({ + response: { ...apiKeyTarget, status: "CREATING" }, + }); + expect(permissions(policies.get(POLICY_NAME)!)).toEqual( + expect.arrayContaining([ + `bedrock-agentcore:GetResourceApiKey ${API_KEY_PROVIDER_ARN}`, + `bedrock-agentcore:GetWorkloadAccessToken ${WORKLOAD_IDENTITY_ARN}`, + `secretsmanager:GetSecretValue ${API_KEY_SECRET_ARN}`, + ]), + ); }); test("returns pending OAuth authorization data for an external role", async () => { diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.5dbeb94f25f7c964.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.5dbeb94f25f7c964.json index e087b10bc..431a49880 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.5dbeb94f25f7c964.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayRuleCommand.5dbeb94f25f7c964.json @@ -16,4 +16,4 @@ }, "status": "CREATING", "description": "Disposable Gateway Rule Create fixture" -} \ No newline at end of file +} diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.959bf5ffe0c36d0b.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.959bf5ffe0c36d0b.json index f0a235839..eee859121 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.959bf5ffe0c36d0b.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.959bf5ffe0c36d0b.json @@ -32,4 +32,4 @@ } ], "description": "Disposable Gateway Connector Create fixture" -} \ No newline at end of file +} diff --git a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.c36b2aed80b6d481.json b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.c36b2aed80b6d481.json index 0e29cc795..f06e399de 100644 --- a/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.c36b2aed80b6d481.json +++ b/src/handlers/gateway/__fixtures__/create/CreateGatewayTargetCommand.c36b2aed80b6d481.json @@ -17,4 +17,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.136772a9fb296e8a.json b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.136772a9fb296e8a.json index 163793568..5aa7ff219 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.136772a9fb296e8a.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayRuleCommand.136772a9fb296e8a.json @@ -19,4 +19,4 @@ "updatedAt": { "$date": "2026-08-12T22:10:14.019Z" } -} \ No newline at end of file +} diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.890798045a9f2d8c.json b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.890798045a9f2d8c.json index 16956513d..7d4080574 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.890798045a9f2d8c.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.890798045a9f2d8c.json @@ -17,4 +17,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.d38d917375a78a69.json b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.d38d917375a78a69.json index 77dd14a59..a7600bebc 100644 --- a/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.d38d917375a78a69.json +++ b/src/handlers/gateway/__fixtures__/create/GetGatewayTargetCommand.d38d917375a78a69.json @@ -33,4 +33,4 @@ } ], "description": "Disposable Gateway Connector Create fixture" -} \ 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 95a53185d..d5ca86d4b 100644 --- a/src/handlers/gateway/__fixtures__/create/connector-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/connector-create.golden.json @@ -28,4 +28,4 @@ } ], "description": "Disposable Gateway Connector Create fixture" -} \ No newline at end of file +} diff --git a/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json index 27cda7236..34e00109f 100644 --- a/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/gateway-create.golden.json @@ -10,4 +10,4 @@ "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/rule-create.golden.json b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json index 699de41b3..b5bdd3b4c 100644 --- a/src/handlers/gateway/__fixtures__/create/rule-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/rule-create.golden.json @@ -14,4 +14,4 @@ "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 92389e3bf..f871b42c1 100644 --- a/src/handlers/gateway/__fixtures__/create/target-create.golden.json +++ b/src/handlers/gateway/__fixtures__/create/target-create.golden.json @@ -13,4 +13,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.fa269fea8c85d5d3.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.fa269fea8c85d5d3.json new file mode 100644 index 000000000..a2f02bc77 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.fa269fea8c85d5d3.json @@ -0,0 +1,4 @@ +{ + "gatewayId": "agentcore-cli-gateway-delete-fixture-bktovgujtu", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.6ab0299b2bdabcea.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.6ab0299b2bdabcea.json new file mode 100644 index 000000000..a88878d87 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.6ab0299b2bdabcea.json @@ -0,0 +1,4 @@ +{ + "ruleId": "3c81b197-57de-48a2-821a-5c7f6110d5a3", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.e81f448d5f5c9cd6.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.e81f448d5f5c9cd6.json new file mode 100644 index 000000000..e2eb1d00f --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.e81f448d5f5c9cd6.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "LPEKLWUKBV", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.faa85b654c0d6036.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.faa85b654c0d6036.json new file mode 100644 index 000000000..0e24494a7 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.faa85b654c0d6036.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "QQW3OGRIPL", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteRolePolicyCommand.e4cae6ff323e48b2.json b/src/handlers/gateway/__fixtures__/delete/DeleteRolePolicyCommand.e4cae6ff323e48b2.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteRolePolicyCommand.e4cae6ff323e48b2.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.fa269fea8c85d5d3.json b/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.fa269fea8c85d5d3.json new file mode 100644 index 000000000..3058db9f8 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.fa269fea8c85d5d3.json @@ -0,0 +1,124 @@ +{ + "$sequence": [ + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "gatewayId": "agentcore-cli-gateway-delete-fixture-bktovgujtu", + "createdAt": { + "$date": "2026-08-12T23:00:38.134Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:39.070Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-delete-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-delete-fixture-bktovgujtu.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Delete fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-delete-fixture-bktovgujtu" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "gatewayId": "agentcore-cli-gateway-delete-fixture-bktovgujtu", + "createdAt": { + "$date": "2026-08-12T23:00:38.134Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:39.070Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-delete-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-delete-fixture-bktovgujtu.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Delete fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-delete-fixture-bktovgujtu" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "gatewayId": "agentcore-cli-gateway-delete-fixture-bktovgujtu", + "createdAt": { + "$date": "2026-08-12T23:00:38.134Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:39.070Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-delete-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-delete-fixture-bktovgujtu.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Delete fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-delete-fixture-bktovgujtu" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "gatewayId": "agentcore-cli-gateway-delete-fixture-bktovgujtu", + "createdAt": { + "$date": "2026-08-12T23:00:38.134Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:39.070Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-delete-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-delete-fixture-bktovgujtu.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Delete fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-delete-fixture-bktovgujtu" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "gatewayId": "agentcore-cli-gateway-delete-fixture-bktovgujtu", + "createdAt": { + "$date": "2026-08-12T23:00:38.134Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:39.070Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-delete-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-delete-fixture-bktovgujtu.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Delete fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-delete-fixture-bktovgujtu" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "gatewayId": "agentcore-cli-gateway-delete-fixture-bktovgujtu", + "createdAt": { + "$date": "2026-08-12T23:00:38.134Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:01:23.764Z" + }, + "status": "DELETING", + "name": "agentcore-cli-gateway-delete-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-delete-fixture-bktovgujtu.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Delete fixture", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-delete-fixture-bktovgujtu" + } + }, + { + "$error": { + "name": "ResourceNotFoundException", + "message": "Failed to retrieve gateway because it doesn't exist. Retry the request with a different resource identifier." + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.e81f448d5f5c9cd6.json b/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.e81f448d5f5c9cd6.json new file mode 100644 index 000000000..9220c95f3 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.e81f448d5f5c9cd6.json @@ -0,0 +1,70 @@ +{ + "$sequence": [ + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "LPEKLWUKBV", + "createdAt": { + "$date": "2026-08-12T23:00:40.485Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:41.055Z" + }, + "status": "READY", + "name": "http-delete-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "LPEKLWUKBV", + "createdAt": { + "$date": "2026-08-12T23:00:40.485Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:53.916Z" + }, + "status": "DELETING", + "name": "http-delete-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "LPEKLWUKBV", + "createdAt": { + "$date": "2026-08-12T23:00:40.485Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:53.916Z" + }, + "status": "DELETING", + "name": "http-delete-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + } + }, + { + "$error": { + "name": "ResourceNotFoundException", + "message": "Failed to retrieve target because it doesn't exist. Retry the request with a different resource identifier." + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.faa85b654c0d6036.json b/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.faa85b654c0d6036.json new file mode 100644 index 000000000..5ff5cd648 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.faa85b654c0d6036.json @@ -0,0 +1,220 @@ +{ + "$sequence": [ + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "QQW3OGRIPL", + "createdAt": { + "$date": "2026-08-12T23:00:40.681Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:41.389Z" + }, + "status": "READY", + "name": "web-search-delete-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ] + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "QQW3OGRIPL", + "createdAt": { + "$date": "2026-08-12T23:00:40.681Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:41.389Z" + }, + "status": "READY", + "name": "web-search-delete-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ] + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "QQW3OGRIPL", + "createdAt": { + "$date": "2026-08-12T23:00:40.681Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:41.389Z" + }, + "status": "READY", + "name": "web-search-delete-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ] + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "QQW3OGRIPL", + "createdAt": { + "$date": "2026-08-12T23:00:40.681Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:41.389Z" + }, + "status": "READY", + "name": "web-search-delete-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ] + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "QQW3OGRIPL", + "createdAt": { + "$date": "2026-08-12T23:00:40.681Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:01:09.060Z" + }, + "status": "DELETING", + "name": "web-search-delete-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ] + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "QQW3OGRIPL", + "createdAt": { + "$date": "2026-08-12T23:00:40.681Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:01:09.060Z" + }, + "status": "DELETING", + "name": "web-search-delete-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ] + }, + { + "$error": { + "name": "ResourceNotFoundException", + "message": "Failed to retrieve target because it doesn't exist. Retry the request with a different resource identifier." + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/GetRoleCommand.35a5e65fc228180b.json b/src/handlers/gateway/__fixtures__/delete/GetRoleCommand.35a5e65fc228180b.json new file mode 100644 index 000000000..d995bc5a6 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/GetRoleCommand.35a5e65fc228180b.json @@ -0,0 +1,32 @@ +{ + "$sequence": [ + { + "Role": { + "Path": "/", + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "RoleId": "AROAYY3QB54N4KODGYT4L", + "Arn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "CreateDate": { + "$date": "2026-08-12T23:00:27.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", + "MaxSessionDuration": 3600, + "RoleLastUsed": {} + } + }, + { + "Role": { + "Path": "/", + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "RoleId": "AROAYY3QB54N4KODGYT4L", + "Arn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "CreateDate": { + "$date": "2026-08-12T23:00:27.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", + "MaxSessionDuration": 3600, + "RoleLastUsed": {} + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/GetRolePolicyCommand.e4cae6ff323e48b2.json b/src/handlers/gateway/__fixtures__/delete/GetRolePolicyCommand.e4cae6ff323e48b2.json new file mode 100644 index 000000000..be3b53247 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/GetRolePolicyCommand.e4cae6ff323e48b2.json @@ -0,0 +1,35 @@ +{ + "$sequence": [ + { + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-3bc4a6ce7ed4eebf", + "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-gateway-delete-fixture-bktovgujtu%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" + }, + { + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-3bc4a6ce7ed4eebf", + "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-gateway-delete-fixture-bktovgujtu%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" + }, + { + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-3bc4a6ce7ed4eebf", + "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-gateway-delete-fixture-bktovgujtu%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" + }, + { + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-3bc4a6ce7ed4eebf", + "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-gateway-delete-fixture-bktovgujtu%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" + }, + { + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-3bc4a6ce7ed4eebf", + "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-gateway-delete-fixture-bktovgujtu%22%5D%7D%5D%2C%22Version%22%3A%222012-10-17%22%7D" + }, + { + "$error": { + "name": "NoSuchEntityException", + "message": "The role policy with name AgentCoreCliGatewayExecutionPolicy-3bc4a6ce7ed4eebf cannot be found." + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/ListGatewayTargetsCommand.f31dfecd43fbfb5b.json b/src/handlers/gateway/__fixtures__/delete/ListGatewayTargetsCommand.f31dfecd43fbfb5b.json new file mode 100644 index 000000000..8b579cf2e --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/ListGatewayTargetsCommand.f31dfecd43fbfb5b.json @@ -0,0 +1,67 @@ +{ + "$sequence": [ + { + "items": [ + { + "targetId": "LPEKLWUKBV", + "name": "http-delete-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:40.485Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:41.055Z" + }, + "targetType": "PASSTHROUGH" + }, + { + "targetId": "QQW3OGRIPL", + "name": "web-search-delete-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:40.681Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:41.389Z" + }, + "targetType": "CONNECTOR" + } + ] + }, + { + "items": [ + { + "targetId": "QQW3OGRIPL", + "name": "web-search-delete-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:40.681Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:41.389Z" + }, + "targetType": "CONNECTOR" + } + ] + }, + { + "items": [ + { + "targetId": "QQW3OGRIPL", + "name": "web-search-delete-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:40.681Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:41.389Z" + }, + "targetType": "CONNECTOR" + } + ] + }, + { + "items": [] + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/ListRolePoliciesCommand.35a5e65fc228180b.json b/src/handlers/gateway/__fixtures__/delete/ListRolePoliciesCommand.35a5e65fc228180b.json new file mode 100644 index 000000000..1be393fc5 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/ListRolePoliciesCommand.35a5e65fc228180b.json @@ -0,0 +1,22 @@ +{ + "$sequence": [ + { + "PolicyNames": [ + "AgentCoreCliGatewayExecutionPolicy-3bc4a6ce7ed4eebf" + ], + "IsTruncated": false + }, + { + "PolicyNames": [ + "AgentCoreCliGatewayExecutionPolicy-3bc4a6ce7ed4eebf" + ], + "IsTruncated": false + }, + { + "PolicyNames": [ + "AgentCoreCliGatewayExecutionPolicy-3bc4a6ce7ed4eebf" + ], + "IsTruncated": false + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/PutRolePolicyCommand.ae394d8043c471be.json b/src/handlers/gateway/__fixtures__/delete/PutRolePolicyCommand.ae394d8043c471be.json new file mode 100644 index 000000000..5bc0e5c05 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/PutRolePolicyCommand.ae394d8043c471be.json @@ -0,0 +1,6 @@ +{ + "$sequence": [ + {}, + {} + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/PutRolePolicyCommand.ce01bb360dff7223.json b/src/handlers/gateway/__fixtures__/delete/PutRolePolicyCommand.ce01bb360dff7223.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/PutRolePolicyCommand.ce01bb360dff7223.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json new file mode 100644 index 000000000..0e24494a7 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "QQW3OGRIPL", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json new file mode 100644 index 000000000..a2f02bc77 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json @@ -0,0 +1,4 @@ +{ + "gatewayId": "agentcore-cli-gateway-delete-fixture-bktovgujtu", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/resources.json b/src/handlers/gateway/__fixtures__/delete/resources.json new file mode 100644 index 000000000..0393bdf12 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/resources.json @@ -0,0 +1,7 @@ +{ + "gatewayId": "agentcore-cli-gateway-delete-fixture-bktovgujtu", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "LPEKLWUKBV", + "connectorId": "QQW3OGRIPL", + "ruleId": "3c81b197-57de-48a2-821a-5c7f6110d5a3" +} diff --git a/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json new file mode 100644 index 000000000..a88878d87 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json @@ -0,0 +1,4 @@ +{ + "ruleId": "3c81b197-57de-48a2-821a-5c7f6110d5a3", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json new file mode 100644 index 000000000..e2eb1d00f --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-delete-fixture-bktovgujtu", + "targetId": "LPEKLWUKBV", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/GetGatewayCommand.74d564114e1a0127.json b/src/handlers/gateway/__fixtures__/update/GetGatewayCommand.74d564114e1a0127.json new file mode 100644 index 000000000..e705cd4fc --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/GetGatewayCommand.74d564114e1a0127.json @@ -0,0 +1,213 @@ +{ + "$sequence": [ + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:28.051Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway before update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:28.051Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway before update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:43.025Z" + }, + "status": "UPDATING", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway before update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:43.490Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway after update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:43.490Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway after update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:43.490Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway after update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:43.490Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway after update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:43.490Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway after update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:43.490Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway after update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:43.490Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway after update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:43.490Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway after update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/GetGatewayTargetCommand.8df3ef5249b90262.json b/src/handlers/gateway/__fixtures__/update/GetGatewayTargetCommand.8df3ef5249b90262.json new file mode 100644 index 000000000..edd0abf8f --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/GetGatewayTargetCommand.8df3ef5249b90262.json @@ -0,0 +1,328 @@ +{ + "$sequence": [ + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "Z6OLJQ9NUE", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.610Z" + }, + "status": "READY", + "name": "web-search-update-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Connector before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "Z6OLJQ9NUE", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.610Z" + }, + "status": "READY", + "name": "web-search-update-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Connector before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "Z6OLJQ9NUE", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.610Z" + }, + "status": "READY", + "name": "web-search-update-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Connector before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "Z6OLJQ9NUE", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.610Z" + }, + "status": "READY", + "name": "web-search-update-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Connector before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "Z6OLJQ9NUE", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.610Z" + }, + "status": "READY", + "name": "web-search-update-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Connector before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "Z6OLJQ9NUE", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.610Z" + }, + "status": "READY", + "name": "web-search-update-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Connector before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "Z6OLJQ9NUE", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:01:10.729Z" + }, + "status": "UPDATING", + "name": "web-search-update-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Connector before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "Z6OLJQ9NUE", + "createdAt": { + "$date": "2026-08-12T23:01:11.868Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:01:11.868Z" + }, + "status": "READY", + "name": "web-search-update-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Connector after update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "Z6OLJQ9NUE", + "createdAt": { + "$date": "2026-08-12T23:01:11.868Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:01:11.868Z" + }, + "status": "READY", + "name": "web-search-update-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Connector after update" + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/GetGatewayTargetCommand.cf72df43f7534187.json b/src/handlers/gateway/__fixtures__/update/GetGatewayTargetCommand.cf72df43f7534187.json new file mode 100644 index 000000000..6cdc28598 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/GetGatewayTargetCommand.cf72df43f7534187.json @@ -0,0 +1,193 @@ +{ + "$sequence": [ + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "createdAt": { + "$date": "2026-08-12T23:00:29.777Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.329Z" + }, + "status": "READY", + "name": "http-update-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + }, + "description": "Target before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "createdAt": { + "$date": "2026-08-12T23:00:29.777Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.329Z" + }, + "status": "READY", + "name": "http-update-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + }, + "description": "Target before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "createdAt": { + "$date": "2026-08-12T23:00:29.777Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.329Z" + }, + "status": "READY", + "name": "http-update-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + }, + "description": "Target before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "createdAt": { + "$date": "2026-08-12T23:00:29.777Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.329Z" + }, + "status": "READY", + "name": "http-update-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + }, + "description": "Target before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "createdAt": { + "$date": "2026-08-12T23:00:29.777Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:56.398Z" + }, + "status": "UPDATING", + "name": "http-update-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + }, + "description": "Target before update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "createdAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "status": "READY", + "name": "http-update-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + }, + "description": "Target after update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "createdAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "status": "READY", + "name": "http-update-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + }, + "description": "Target after update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "createdAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "status": "READY", + "name": "http-update-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + }, + "description": "Target after update" + }, + { + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "createdAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "status": "READY", + "name": "http-update-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + }, + "description": "Target after update" + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/GetRoleCommand.c4df92d084fa2e42.json b/src/handlers/gateway/__fixtures__/update/GetRoleCommand.c4df92d084fa2e42.json new file mode 100644 index 000000000..8f9a1924c --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/GetRoleCommand.c4df92d084fa2e42.json @@ -0,0 +1,46 @@ +{ + "$sequence": [ + { + "Role": { + "Path": "/", + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "RoleId": "AROAYY3QB54NYXO2QAKKT", + "Arn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "CreateDate": { + "$date": "2026-08-12T23:00:27.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", + "MaxSessionDuration": 3600, + "RoleLastUsed": {} + } + }, + { + "Role": { + "Path": "/", + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "RoleId": "AROAYY3QB54NYXO2QAKKT", + "Arn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "CreateDate": { + "$date": "2026-08-12T23:00:27.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", + "MaxSessionDuration": 3600, + "RoleLastUsed": {} + } + }, + { + "Role": { + "Path": "/", + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "RoleId": "AROAYY3QB54NYXO2QAKKT", + "Arn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "CreateDate": { + "$date": "2026-08-12T23:00:27.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", + "MaxSessionDuration": 3600, + "RoleLastUsed": {} + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/GetRolePolicyCommand.3faba32d55315f3f.json b/src/handlers/gateway/__fixtures__/update/GetRolePolicyCommand.3faba32d55315f3f.json new file mode 100644 index 000000000..29c6adb5d --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/GetRolePolicyCommand.3faba32d55315f3f.json @@ -0,0 +1,34 @@ +{ + "$sequence": [ + { + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-1aa28118165a374a", + "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-gateway-update-fixture-oekefzo7r6%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" + }, + { + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-1aa28118165a374a", + "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-gateway-update-fixture-oekefzo7r6%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" + }, + { + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-1aa28118165a374a", + "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-gateway-update-fixture-oekefzo7r6%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" + }, + { + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-1aa28118165a374a", + "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-gateway-update-fixture-oekefzo7r6%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" + }, + { + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-1aa28118165a374a", + "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-gateway-update-fixture-oekefzo7r6%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" + }, + { + "RoleName": "AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "PolicyName": "AgentCoreCliGatewayExecutionPolicy-1aa28118165a374a", + "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-gateway-update-fixture-oekefzo7r6%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__/update/ListGatewayTargetsCommand.e2785a3a812d2115.json b/src/handlers/gateway/__fixtures__/update/ListGatewayTargetsCommand.e2785a3a812d2115.json new file mode 100644 index 000000000..5c709d9f4 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/ListGatewayTargetsCommand.e2785a3a812d2115.json @@ -0,0 +1,184 @@ +{ + "$sequence": [ + { + "items": [ + { + "targetId": "GUYJRMTLLR", + "name": "http-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:29.777Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.329Z" + }, + "description": "Target before update", + "targetType": "PASSTHROUGH" + }, + { + "targetId": "Z6OLJQ9NUE", + "name": "web-search-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.610Z" + }, + "description": "Connector before update", + "targetType": "CONNECTOR" + } + ] + }, + { + "items": [ + { + "targetId": "GUYJRMTLLR", + "name": "http-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:29.777Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.329Z" + }, + "description": "Target before update", + "targetType": "PASSTHROUGH" + }, + { + "targetId": "Z6OLJQ9NUE", + "name": "web-search-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.610Z" + }, + "description": "Connector before update", + "targetType": "CONNECTOR" + } + ] + }, + { + "items": [ + { + "targetId": "GUYJRMTLLR", + "name": "http-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:29.777Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.329Z" + }, + "description": "Target before update", + "targetType": "PASSTHROUGH" + }, + { + "targetId": "Z6OLJQ9NUE", + "name": "web-search-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.610Z" + }, + "description": "Connector before update", + "targetType": "CONNECTOR" + } + ] + }, + { + "items": [ + { + "targetId": "GUYJRMTLLR", + "name": "http-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "description": "Target after update", + "targetType": "PASSTHROUGH" + }, + { + "targetId": "Z6OLJQ9NUE", + "name": "web-search-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.610Z" + }, + "description": "Connector before update", + "targetType": "CONNECTOR" + } + ] + }, + { + "items": [ + { + "targetId": "GUYJRMTLLR", + "name": "http-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "description": "Target after update", + "targetType": "PASSTHROUGH" + }, + { + "targetId": "Z6OLJQ9NUE", + "name": "web-search-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:30.610Z" + }, + "description": "Connector before update", + "targetType": "CONNECTOR" + } + ] + }, + { + "items": [ + { + "targetId": "GUYJRMTLLR", + "name": "http-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:57.229Z" + }, + "description": "Target after update", + "targetType": "PASSTHROUGH" + }, + { + "targetId": "Z6OLJQ9NUE", + "name": "web-search-update-fixture", + "status": "READY", + "createdAt": { + "$date": "2026-08-12T23:01:11.868Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:01:11.868Z" + }, + "description": "Connector after update", + "targetType": "CONNECTOR" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/ListRolePoliciesCommand.c4df92d084fa2e42.json b/src/handlers/gateway/__fixtures__/update/ListRolePoliciesCommand.c4df92d084fa2e42.json new file mode 100644 index 000000000..8ecc37ab6 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/ListRolePoliciesCommand.c4df92d084fa2e42.json @@ -0,0 +1,22 @@ +{ + "$sequence": [ + { + "PolicyNames": [ + "AgentCoreCliGatewayExecutionPolicy-1aa28118165a374a" + ], + "IsTruncated": false + }, + { + "PolicyNames": [ + "AgentCoreCliGatewayExecutionPolicy-1aa28118165a374a" + ], + "IsTruncated": false + }, + { + "PolicyNames": [ + "AgentCoreCliGatewayExecutionPolicy-1aa28118165a374a" + ], + "IsTruncated": false + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/PutRolePolicyCommand.1b1c8cbb07066eb2.json b/src/handlers/gateway/__fixtures__/update/PutRolePolicyCommand.1b1c8cbb07066eb2.json new file mode 100644 index 000000000..ad97ffebf --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/PutRolePolicyCommand.1b1c8cbb07066eb2.json @@ -0,0 +1,7 @@ +{ + "$sequence": [ + {}, + {}, + {} + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/UpdateGatewayCommand.d061aef5d0d9c02b.json b/src/handlers/gateway/__fixtures__/update/UpdateGatewayCommand.d061aef5d0d9c02b.json new file mode 100644 index 000000000..ad13604a9 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/UpdateGatewayCommand.d061aef5d0d9c02b.json @@ -0,0 +1,19 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": { + "$date": "2026-08-12T23:00:27.392Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:43.025Z" + }, + "status": "UPDATING", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway after update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/UpdateGatewayRuleCommand.408cd14f2c58f937.json b/src/handlers/gateway/__fixtures__/update/UpdateGatewayRuleCommand.408cd14f2c58f937.json new file mode 100644 index 000000000..47726ef1d --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/UpdateGatewayRuleCommand.408cd14f2c58f937.json @@ -0,0 +1,22 @@ +{ + "ruleId": "98d3fe46-46a2-4f45-b7b8-0f76bd2e2c41", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "priority": 20, + "actions": [ + { + "routeToTarget": { + "staticRoute": { + "targetName": "http-update-fixture" + } + } + } + ], + "createdAt": { + "$date": "2026-08-12T23:00:32.264Z" + }, + "status": "UPDATING", + "description": "Rule after update", + "updatedAt": { + "$date": "2026-08-12T23:01:13.460Z" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/UpdateGatewayTargetCommand.8fa8b08d399b053f.json b/src/handlers/gateway/__fixtures__/update/UpdateGatewayTargetCommand.8fa8b08d399b053f.json new file mode 100644 index 000000000..cffbac6ea --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/UpdateGatewayTargetCommand.8fa8b08d399b053f.json @@ -0,0 +1,36 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "Z6OLJQ9NUE", + "createdAt": { + "$date": "2026-08-12T23:00:29.966Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:01:10.729Z" + }, + "status": "UPDATING", + "name": "web-search-update-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Connector after update" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/UpdateGatewayTargetCommand.df8aab4dab34f692.json b/src/handlers/gateway/__fixtures__/update/UpdateGatewayTargetCommand.df8aab4dab34f692.json new file mode 100644 index 000000000..8f70c5405 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/UpdateGatewayTargetCommand.df8aab4dab34f692.json @@ -0,0 +1,21 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "createdAt": { + "$date": "2026-08-12T23:00:29.777Z" + }, + "updatedAt": { + "$date": "2026-08-12T23:00:56.398Z" + }, + "status": "UPDATING", + "name": "http-update-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + }, + "description": "Target after update" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/connector-update.golden.json b/src/handlers/gateway/__fixtures__/update/connector-update.golden.json new file mode 100644 index 000000000..de4273c06 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/connector-update.golden.json @@ -0,0 +1,32 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "Z6OLJQ9NUE", + "createdAt": "2026-08-12T23:00:29.966Z", + "updatedAt": "2026-08-12T23:01:10.729Z", + "status": "UPDATING", + "name": "web-search-update-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search", + "version": "1.1.0" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ], + "description": "Connector after update" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/gateway-update.golden.json b/src/handlers/gateway/__fixtures__/update/gateway-update.golden.json new file mode 100644 index 000000000..33277137e --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/gateway-update.golden.json @@ -0,0 +1,15 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "createdAt": "2026-08-12T23:00:27.392Z", + "updatedAt": "2026-08-12T23:00:43.025Z", + "status": "UPDATING", + "name": "agentcore-cli-gateway-update-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-update-fixture-oekefzo7r6.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Gateway after update", + "roleArn": "arn:aws:iam::603141041947:role/AgentCoreCliGateway-agentcore-cli-gateway-update-fixture", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-update-fixture-oekefzo7r6" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/resources.json b/src/handlers/gateway/__fixtures__/update/resources.json new file mode 100644 index 000000000..098240d5c --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/resources.json @@ -0,0 +1,7 @@ +{ + "gatewayId": "agentcore-cli-gateway-update-fixture-oekefzo7r6", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "connectorId": "Z6OLJQ9NUE", + "ruleId": "98d3fe46-46a2-4f45-b7b8-0f76bd2e2c41" +} diff --git a/src/handlers/gateway/__fixtures__/update/rule-update.golden.json b/src/handlers/gateway/__fixtures__/update/rule-update.golden.json new file mode 100644 index 000000000..db4349bcb --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/rule-update.golden.json @@ -0,0 +1,18 @@ +{ + "ruleId": "98d3fe46-46a2-4f45-b7b8-0f76bd2e2c41", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "priority": 20, + "actions": [ + { + "routeToTarget": { + "staticRoute": { + "targetName": "http-update-fixture" + } + } + } + ], + "createdAt": "2026-08-12T23:00:32.264Z", + "status": "UPDATING", + "description": "Rule after update", + "updatedAt": "2026-08-12T23:01:13.460Z" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/update/target-update.golden.json b/src/handlers/gateway/__fixtures__/update/target-update.golden.json new file mode 100644 index 000000000..d7c98ba68 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/update/target-update.golden.json @@ -0,0 +1,17 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:gateway/agentcore-cli-gateway-update-fixture-oekefzo7r6", + "targetId": "GUYJRMTLLR", + "createdAt": "2026-08-12T23:00:29.777Z", + "updatedAt": "2026-08-12T23:00:56.398Z", + "status": "UPDATING", + "name": "http-update-fixture", + "targetConfiguration": { + "http": { + "passthrough": { + "endpoint": "https://example.com", + "protocolType": "CUSTOM" + } + } + }, + "description": "Target after update" +} \ No newline at end of file diff --git a/src/handlers/gateway/connector/delete/index.tsx b/src/handlers/gateway/connector/delete/index.tsx new file mode 100644 index 000000000..52df6eba2 --- /dev/null +++ b/src/handlers/gateway/connector/delete/index.tsx @@ -0,0 +1,34 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; + +export const createDeleteGatewayConnectorHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a connector-backed Gateway Target", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag("id", "the connector-backed Gateway Target ID", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (!flags.id) { + throw new InputValidationError("required option '--id ' not specified"); + } + + const options = coreOptsFromCtx(ctx); + const target = await core.gateway.getGatewayTarget(flags["gateway-id"], flags.id, options); + if (!GatewayConnectorTarget.is(target.targetConfiguration)) { + throw new InputValidationError(`Gateway Target "${flags.id}" is not connector-backed`); + } + ctx + .require(JsonRendererKey) + .renderJson(await core.gateway.deleteGatewayTarget(flags["gateway-id"], flags.id, options)); + }, + }); diff --git a/src/handlers/gateway/connector/get/index.tsx b/src/handlers/gateway/connector/get/index.tsx index 8825e067f..3d4d3a715 100644 --- a/src/handlers/gateway/connector/get/index.tsx +++ b/src/handlers/gateway/connector/get/index.tsx @@ -1,9 +1,9 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -import { InputValidationError } from "../../../../errors"; export const createGetGatewayConnectorHandler = (core: Core) => createHandler({ diff --git a/src/handlers/gateway/connector/index.tsx b/src/handlers/gateway/connector/index.tsx index 6a12f39ae..60cb93563 100644 --- a/src/handlers/gateway/connector/index.tsx +++ b/src/handlers/gateway/connector/index.tsx @@ -3,14 +3,18 @@ import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; import { createCreateGatewayConnectorHandler } from "./create"; +import { createDeleteGatewayConnectorHandler } from "./delete"; import { createGetGatewayConnectorHandler } from "./get"; import { createListGatewayConnectorsHandler } from "./list"; +import { createUpdateGatewayConnectorHandler } from "./update"; export function createGatewayConnectorHandler(core: Core, io: AppIO): Router { return new Router("connector", "inspect connectors configured for an AgentCore Gateway") .default(renderTui(core, io)) .supportedTuiCommands("get", "list") .handler(createCreateGatewayConnectorHandler(core, io)) + .handler(createUpdateGatewayConnectorHandler(core, io)) .handler(createGetGatewayConnectorHandler(core)) - .handler(createListGatewayConnectorsHandler(core)); + .handler(createListGatewayConnectorsHandler(core)) + .handler(createDeleteGatewayConnectorHandler(core)); } diff --git a/src/handlers/gateway/connector/update/index.tsx b/src/handlers/gateway/connector/update/index.tsx new file mode 100644 index 000000000..ca09ad8e6 --- /dev/null +++ b/src/handlers/gateway/connector/update/index.tsx @@ -0,0 +1,178 @@ +import type { + CredentialProviderConfiguration, + MetadataConfiguration, + PrivateEndpoint, + TargetConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { + assertMutuallyExclusiveInputs, + coreOptsFromCtx, + parseJsonArrayFlag, + parseJsonObjectFlag, +} from "../../../utils"; +import type { GatewayTargetUpdatePatch } from "../../types"; +import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; +import { warnForGatewayRolePolicyUpdate } from "../../rolePolicyWarning"; + +export const createUpdateGatewayConnectorHandler = (core: Core, io: AppIO) => + createHandler({ + name: "update", + description: "update a connector-backed Gateway Target", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag("id", "the connector-backed Gateway Target ID", z.string().optional()), + flag("name", "updated Connector Target name", z.string().optional()), + flag("description", "updated Connector Target description", z.string().optional()), + flag( + "connector-configuration", + "complete connector-backed Target configuration (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "connector", + "curated connector", + z.enum(["web-search", "bedrock-knowledge-bases", "bedrock-mantle"]).optional(), + ), + flag( + "knowledge-base-id", + "Knowledge Base ID for the bedrock-knowledge-bases connector", + z.string().optional(), + ), + flag( + "credential-provider-configurations", + "replacement outbound credentials (JSON array; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "metadata-configuration", + "replacement metadata propagation (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "private-endpoint", + "replacement private endpoint (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag("clear-description", "remove the Connector Target description", z.boolean()), + flag("clear-credential-provider-configurations", "remove outbound credentials", z.boolean()), + flag("clear-metadata-configuration", "remove metadata propagation", z.boolean()), + flag("clear-private-endpoint", "remove private endpoint configuration", z.boolean()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (!flags.id) { + throw new InputValidationError("required option '--id ' not specified"); + } + assertMutuallyExclusiveInputs([ + ["connector", flags.connector, "connector-configuration", flags["connector-configuration"]], + [ + "description", + flags.description, + "clear-description", + flags["clear-description"] || undefined, + ], + [ + "credential-provider-configurations", + flags["credential-provider-configurations"], + "clear-credential-provider-configurations", + flags["clear-credential-provider-configurations"] || undefined, + ], + [ + "metadata-configuration", + flags["metadata-configuration"], + "clear-metadata-configuration", + flags["clear-metadata-configuration"] || undefined, + ], + [ + "private-endpoint", + flags["private-endpoint"], + "clear-private-endpoint", + flags["clear-private-endpoint"] || undefined, + ], + ]); + if ( + flags.connector === "bedrock-knowledge-bases" && + flags["knowledge-base-id"] === undefined + ) { + throw new InputValidationError( + "--connector bedrock-knowledge-bases requires --knowledge-base-id", + ); + } + if ( + flags["knowledge-base-id"] !== undefined && + flags.connector !== "bedrock-knowledge-bases" + ) { + throw new InputValidationError( + "--knowledge-base-id requires --connector bedrock-knowledge-bases", + ); + } + + const source = new SourceResolver({ stdin: io.stdin }); + const exactConfiguration = parseJsonObjectFlag( + "connector-configuration", + await source.resolveText("connector-configuration", flags["connector-configuration"]), + ); + if (exactConfiguration && !GatewayConnectorTarget.is(exactConfiguration)) { + throw new InputValidationError( + "--connector-configuration must contain an MCP or inference connector Target", + ); + } + const targetConfiguration = + exactConfiguration ?? + (flags.connector + ? GatewayConnectorTarget.fromShortcut(flags.connector, flags["knowledge-base-id"]) + : undefined); + const credentialProviderConfigurations = parseJsonArrayFlag( + "credential-provider-configurations", + await source.resolveText( + "credential-provider-configurations", + flags["credential-provider-configurations"], + ), + ); + const metadataConfiguration = parseJsonObjectFlag( + "metadata-configuration", + await source.resolveText("metadata-configuration", flags["metadata-configuration"]), + ); + const privateEndpoint = parseJsonObjectFlag( + "private-endpoint", + await source.resolveText("private-endpoint", flags["private-endpoint"]), + ); + + const mutations: Omit = { + name: flags.name, + description: flags["clear-description"] ? null : flags.description, + targetConfiguration, + credentialProviderConfigurations: flags["clear-credential-provider-configurations"] + ? null + : credentialProviderConfigurations, + metadataConfiguration: flags["clear-metadata-configuration"] ? null : metadataConfiguration, + privateEndpoint: flags["clear-private-endpoint"] ? null : privateEndpoint, + }; + if (Object.values(mutations).every((value) => value === undefined)) { + throw new InputValidationError("Connector update requires at least one mutation option"); + } + const patch: GatewayTargetUpdatePatch = { + gatewayId: flags["gateway-id"], + targetId: flags.id, + ...mutations, + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), + }; + + const options = coreOptsFromCtx(ctx); + await warnForGatewayRolePolicyUpdate(core, io, flags["gateway-id"], options, { + skipRolePolicyUpdate: flags["skip-role-policy-update"], + }); + ctx + .require(JsonRendererKey) + .renderJson(await core.gateway.updateGatewayConnector(patch, options)); + }, + }); diff --git a/src/handlers/gateway/delete/index.tsx b/src/handlers/gateway/delete/index.tsx new file mode 100644 index 000000000..ddd27389a --- /dev/null +++ b/src/handlers/gateway/delete/index.tsx @@ -0,0 +1,21 @@ +import z from "zod"; +import { InputValidationError } from "../../../errors"; +import { createHandler, flag } from "../../../router"; +import { JsonRendererKey } from "../../../tui"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; + +export const createDeleteGatewayHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete an AgentCore Gateway", + flags: [flag("id", "the Gateway ID", z.string().optional())], + handle: async (ctx, flags) => { + if (!flags.id) { + throw new InputValidationError("required option '--id ' not specified"); + } + ctx + .require(JsonRendererKey) + .renderJson(await core.gateway.deleteGateway(flags.id, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/gateway/gateway.delete.test.tsx b/src/handlers/gateway/gateway.delete.test.tsx new file mode 100644 index 000000000..28fcbebc2 --- /dev/null +++ b/src/handlers/gateway/gateway.delete.test.tsx @@ -0,0 +1,588 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + CreateGatewayCommand, + CreateGatewayRuleCommand, + CreateGatewayTargetCommand, + DeleteGatewayCommand, + DeleteGatewayRuleCommand, + DeleteGatewayTargetCommand, + type DeleteGatewayResponse, + type DeleteGatewayRuleResponse, + type DeleteGatewayTargetResponse, + GetGatewayCommand, + GetGatewayRuleCommand, + GetGatewayTargetCommand, + type GetGatewayTargetResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + PutRolePolicyCommand, +} from "@aws-sdk/client-iam"; +import { CoreClient } from "../../core"; +import { createControlClient, createIamClient } from "../../core/factories"; +import { ExecutionRoleManager } from "../../core/executionRoleManager"; +import { + createSilentLogger, + fixtureFactories, + isRecording, + matchGolden, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; +import { createRootHandler } from "../index"; + +const REGION = "us-west-2"; +const GATEWAY_ID = "gateway-1"; +const TARGET_ID = "target-1"; +const RULE_ID = "rule-1"; + +async function run( + args: string[], + core = new TestCoreClient(), +): Promise<{ core: TestCoreClient; stdout: string }> { + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return { core, stdout: io.stdout() }; +} + +describe("gateway delete commands", () => { + test("deletes a Gateway", async () => { + const response = { gatewayId: GATEWAY_ID, status: "DELETING" } as DeleteGatewayResponse; + const core = new TestCoreClient(); + core.gateway.setDeleteResponse(response); + + const result = await run(["gateway", "delete", "--id", GATEWAY_ID], core); + + expect(core.gateway.calls).toEqual([ + { + method: "deleteGateway", + args: [GATEWAY_ID, { region: REGION }], + }, + ]); + expect(JSON.parse(result.stdout)).toEqual(response); + }); + + test("deletes a Target", async () => { + const response = { targetId: TARGET_ID, status: "DELETING" } as DeleteGatewayTargetResponse; + const core = new TestCoreClient(); + core.gateway.setDeleteTargetResponse(response); + + const result = await run( + ["gateway", "target", "delete", "--gateway-id", GATEWAY_ID, "--target-id", TARGET_ID], + core, + ); + + expect(core.gateway.calls).toEqual([ + { + method: "deleteGatewayTarget", + args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + }, + ]); + expect(JSON.parse(result.stdout)).toEqual(response); + }); + + test("deletes a connector-backed Target", async () => { + const response = { targetId: TARGET_ID, status: "DELETING" } as DeleteGatewayTargetResponse; + const core = new TestCoreClient(); + core.gateway + .setGetTargetResponse({ + targetId: TARGET_ID, + targetConfiguration: { + mcp: { connector: { source: { connectorId: "web-search" } } }, + }, + } as GetGatewayTargetResponse) + .setDeleteTargetResponse(response); + + const result = await run( + ["gateway", "connector", "delete", "--gateway-id", GATEWAY_ID, "--id", TARGET_ID], + core, + ); + + expect(core.gateway.calls).toEqual([ + { + method: "getGatewayTarget", + args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + }, + { + method: "deleteGatewayTarget", + args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + }, + ]); + expect(JSON.parse(result.stdout)).toEqual(response); + }); + + test("rejects a non-connector Target without deleting it", async () => { + const core = new TestCoreClient(); + core.gateway.setGetTargetResponse({ + targetId: TARGET_ID, + targetConfiguration: { + http: { passthrough: { endpoint: "https://example.test", protocolType: "CUSTOM" } }, + }, + } as GetGatewayTargetResponse); + + await expect( + run(["gateway", "connector", "delete", "--gateway-id", GATEWAY_ID, "--id", TARGET_ID], core), + ).rejects.toThrow(/not connector-backed/); + expect(core.gateway.calls).toEqual([ + { + method: "getGatewayTarget", + args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + }, + ]); + }); + + test("deletes a Rule", async () => { + const response = { ruleId: RULE_ID, status: "DELETING" } as DeleteGatewayRuleResponse; + const core = new TestCoreClient(); + core.gateway.setDeleteRuleResponse(response); + + const result = await run( + ["gateway", "rule", "delete", "--gateway-id", GATEWAY_ID, "--rule-id", RULE_ID], + core, + ); + + expect(core.gateway.calls).toEqual([ + { + method: "deleteGatewayRule", + args: [GATEWAY_ID, RULE_ID, { region: REGION }], + }, + ]); + expect(JSON.parse(result.stdout)).toEqual(response); + }); +}); + +describe("gateway delete validation", () => { + test.each([ + ["Gateway selector", ["gateway", "delete"], /--id/], + ["Target parent", ["gateway", "target", "delete"], /--gateway-id/], + ["Target selector", ["gateway", "target", "delete", "--gateway-id", GATEWAY_ID], /--target-id/], + ["Connector parent", ["gateway", "connector", "delete"], /--gateway-id/], + ["Connector selector", ["gateway", "connector", "delete", "--gateway-id", GATEWAY_ID], /--id/], + ["Rule parent", ["gateway", "rule", "delete"], /--gateway-id/], + ["Rule selector", ["gateway", "rule", "delete", "--gateway-id", GATEWAY_ID], /--rule-id/], + ] as const)("rejects a missing %s before calling Core", async (_name, args, error) => { + const core = new TestCoreClient(); + + await expect(run([...args], core)).rejects.toThrow(error); + expect(core.gateway.calls).toEqual([]); + }); +}); + +const FIXTURES = join(import.meta.dir, "__fixtures__", "delete"); +const RESOURCE_STATE = join(FIXTURES, "resources.json"); +const GATEWAY_NAME = "agentcore-cli-gateway-delete-fixture"; +const ROLE_NAME = "AgentCoreCliGateway-agentcore-cli-gateway-delete-fixture"; +const POLICY_NAME = ExecutionRoleManager.generatedPolicyName("gateway", { + accountId: "603141041947", + region: "us-east-1", + stableResourceKey: ROLE_NAME, +}); +const HTTP_TARGET_NAME = "http-delete-fixture"; +const CONNECTOR_TARGET_NAME = "web-search-delete-fixture"; +const FLOW_TIMEOUT = 600_000; + +type FixtureState = { + gatewayId: string; + gatewayArn: string; + targetId: string; + connectorId: string; + ruleId: string; +}; + +type FixtureResources = { + gatewayId?: string; + targetIds: string[]; + ruleId?: string; +}; + +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient, createLogsClient } = + fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + gatewayOptions: isRecording() + ? undefined + : { + policyUpdater: { + propagationDelayMs: 0, + retryDelayMs: 0, + }, + waitDelayMs: 0, + }, + }); +} + +async function runFixture(args: string[]): Promise { + const io = testIO(); + const root = createRootHandler(createFixtureCore(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", ...args, "--region", "us-east-1"]); + return io.stdout(); +} + +class GatewayDeleteFixture { + private readonly control = createControlClient({ region: "us-east-1" }); + private readonly iam = createIamClient({ region: "us-east-1" }); + + async setup(resources: FixtureResources): Promise { + await this.ignoreMissing(() => + this.iam.send(new DeleteRolePolicyCommand({ RoleName: ROLE_NAME, PolicyName: POLICY_NAME })), + ); + await this.ignoreMissing(() => this.iam.send(new DeleteRoleCommand({ RoleName: ROLE_NAME }))); + const role = await this.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 (!role.Role?.Arn) throw new Error("IAM did not return the fixture role ARN"); + await Bun.sleep(10_000); + + const gateway = await this.control.send( + new CreateGatewayCommand({ + name: GATEWAY_NAME, + roleArn: role.Role.Arn, + authorizerType: "NONE", + description: "Disposable Gateway Delete fixture", + }), + ); + if (!gateway.gatewayId || !gateway.gatewayArn) { + throw new Error("CreateGateway did not return fixture identifiers"); + } + resources.gatewayId = gateway.gatewayId; + await this.waitUntil( + () => this.control.send(new GetGatewayCommand({ gatewayIdentifier: gateway.gatewayId })), + (response) => response.status === "READY", + ); + + await this.iam.send( + new PutRolePolicyCommand({ + RoleName: ROLE_NAME, + PolicyName: POLICY_NAME, + PolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeGateway", + Resource: gateway.gatewayArn, + }, + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeWebSearch", + Resource: "arn:aws:bedrock-agentcore:us-east-1:aws:tool/web-search.v1", + }, + ], + }), + }), + ); + + const target = await this.control.send( + new CreateGatewayTargetCommand({ + gatewayIdentifier: gateway.gatewayId, + name: HTTP_TARGET_NAME, + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://example.com", + protocolType: "CUSTOM", + }, + }, + }, + }), + ); + if (!target.targetId) { + throw new Error("CreateGatewayTarget did not return the fixture Target ID"); + } + resources.targetIds.push(target.targetId); + + const connector = await this.control.send( + new CreateGatewayTargetCommand({ + gatewayIdentifier: gateway.gatewayId, + name: CONNECTOR_TARGET_NAME, + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "web-search" }, + configurations: [ + { + name: "WebSearch", + parameterValues: { maxResults: 10 }, + }, + ], + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }), + ); + if (!connector.targetId) { + throw new Error("CreateGatewayTarget did not return the fixture Connector ID"); + } + resources.targetIds.push(connector.targetId); + await Promise.all( + [target.targetId, connector.targetId].map((targetId) => + this.waitUntil( + () => + this.control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: gateway.gatewayId, + targetId, + }), + ), + (response) => response.status === "READY", + ), + ), + ); + + const rule = await this.control.send( + new CreateGatewayRuleCommand({ + gatewayIdentifier: gateway.gatewayId, + priority: 10, + actions: [ + { + routeToTarget: { + staticRoute: { + targetName: HTTP_TARGET_NAME, + }, + }, + }, + ], + }), + ); + if (!rule.ruleId) throw new Error("CreateGatewayRule did not return the fixture rule ID"); + resources.ruleId = rule.ruleId; + await this.waitUntil( + () => + this.control.send( + new GetGatewayRuleCommand({ + gatewayIdentifier: gateway.gatewayId, + ruleId: rule.ruleId, + }), + ), + (response) => response.status === "ACTIVE", + ); + + const state = { + gatewayId: gateway.gatewayId, + gatewayArn: gateway.gatewayArn, + targetId: target.targetId, + connectorId: connector.targetId, + ruleId: rule.ruleId, + }; + mkdirSync(FIXTURES, { recursive: true }); + writeFileSync(RESOURCE_STATE, `${JSON.stringify(state, null, 2)}\n`); + return state; + } + + async verifyMissing(operation: () => Promise): Promise { + if (!isRecording()) return; + await this.waitUntilMissing(operation); + } + + async cleanup(resources: FixtureResources): Promise { + if (resources.gatewayId && resources.ruleId) { + await this.ignoreMissing(() => + this.control.send( + new DeleteGatewayRuleCommand({ + gatewayIdentifier: resources.gatewayId, + ruleId: resources.ruleId, + }), + ), + ); + await this.waitUntilMissing(() => + this.control.send( + new GetGatewayRuleCommand({ + gatewayIdentifier: resources.gatewayId, + ruleId: resources.ruleId, + }), + ), + ); + } + + if (resources.gatewayId) { + for (const targetId of resources.targetIds) { + await this.ignoreMissing(() => + this.control.send( + new DeleteGatewayTargetCommand({ + gatewayIdentifier: resources.gatewayId, + targetId, + }), + ), + ); + await this.waitUntilMissing(() => + this.control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: resources.gatewayId, + targetId, + }), + ), + ); + } + await this.ignoreMissing(() => + this.control.send(new DeleteGatewayCommand({ gatewayIdentifier: resources.gatewayId })), + ); + await this.waitUntilMissing(() => + this.control.send(new GetGatewayCommand({ gatewayIdentifier: resources.gatewayId })), + ); + } + + await this.ignoreMissing(() => + this.iam.send( + new DeleteRolePolicyCommand({ + RoleName: ROLE_NAME, + PolicyName: POLICY_NAME, + }), + ), + ); + await this.ignoreMissing(() => this.iam.send(new DeleteRoleCommand({ RoleName: ROLE_NAME }))); + } + + private async waitUntil( + operation: () => Promise, + done: (response: T) => boolean, + ): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + const response = await operation(); + if (done(response)) return response; + await Bun.sleep(2_000); + } + throw new Error("Timed out waiting for fixture resource state"); + } + + private async waitUntilMissing(operation: () => Promise): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + await operation(); + } catch (error) { + if ((error as Error).name === "ResourceNotFoundException") return; + throw error; + } + await Bun.sleep(2_000); + } + throw new Error("Timed out waiting for fixture resource deletion"); + } + + private async ignoreMissing(operation: () => Promise): Promise { + try { + await operation(); + } catch (error) { + if (!["ResourceNotFoundException", "NoSuchEntityException"].includes((error as Error).name)) { + throw error; + } + } + } +} + +test( + "deletes a Rule, Target, Connector, and Gateway through the real Core", + async () => { + const fixture = new GatewayDeleteFixture(); + const resources: FixtureResources = { targetIds: [] }; + + try { + const state = isRecording() + ? await fixture.setup(resources) + : (JSON.parse(readFileSync(RESOURCE_STATE, "utf8")) as FixtureState); + + const ruleStdout = await runFixture([ + "gateway", + "rule", + "delete", + "--gateway-id", + state.gatewayId, + "--rule-id", + state.ruleId, + ]); + matchGolden(FIXTURES, "rule-delete.golden.json", ruleStdout); + expect(JSON.parse(ruleStdout).ruleId).toBe(state.ruleId); + await fixture.verifyMissing(() => + createControlClient({ region: "us-east-1" }).send( + new GetGatewayRuleCommand({ + gatewayIdentifier: state.gatewayId, + ruleId: state.ruleId, + }), + ), + ); + + const targetStdout = await runFixture([ + "gateway", + "target", + "delete", + "--gateway-id", + state.gatewayId, + "--target-id", + state.targetId, + ]); + matchGolden(FIXTURES, "target-delete.golden.json", targetStdout); + expect(JSON.parse(targetStdout).targetId).toBe(state.targetId); + await fixture.verifyMissing(() => + createControlClient({ region: "us-east-1" }).send( + new GetGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId: state.targetId, + }), + ), + ); + + const connectorStdout = await runFixture([ + "gateway", + "connector", + "delete", + "--gateway-id", + state.gatewayId, + "--id", + state.connectorId, + ]); + matchGolden(FIXTURES, "connector-delete.golden.json", connectorStdout); + expect(JSON.parse(connectorStdout).targetId).toBe(state.connectorId); + await fixture.verifyMissing(() => + createControlClient({ region: "us-east-1" }).send( + new GetGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId: state.connectorId, + }), + ), + ); + + const gatewayStdout = await runFixture(["gateway", "delete", "--id", state.gatewayId]); + matchGolden(FIXTURES, "gateway-delete.golden.json", gatewayStdout); + expect(JSON.parse(gatewayStdout).gatewayId).toBe(state.gatewayId); + await fixture.verifyMissing(() => + createControlClient({ region: "us-east-1" }).send( + new GetGatewayCommand({ gatewayIdentifier: state.gatewayId }), + ), + ); + } finally { + if (isRecording()) await fixture.cleanup(resources); + } + }, + FLOW_TIMEOUT, +); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index 00b73bd90..8f7fb8d70 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -61,15 +61,35 @@ describe("gateway command hierarchy", () => { expect(gateway?.flags().map((flag) => flag.name)).not.toContain("interactive"); expect(gateway?.children().map((child) => child.name())).toEqual([ "create", + "update", "get", "list", + "delete", "target", "connector", "rule", ]); - expect(target?.children().map((child) => child.name())).toEqual(["create", "get", "list"]); - expect(connector?.children().map((child) => child.name())).toEqual(["create", "get", "list"]); - expect(rule?.children().map((child) => child.name())).toEqual(["create", "get", "list"]); + expect(target?.children().map((child) => child.name())).toEqual([ + "create", + "update", + "get", + "list", + "delete", + ]); + expect(connector?.children().map((child) => child.name())).toEqual([ + "create", + "update", + "get", + "list", + "delete", + ]); + expect(rule?.children().map((child) => child.name())).toEqual([ + "create", + "update", + "get", + "list", + "delete", + ]); }); test.each([ @@ -207,3 +227,66 @@ describe("gateway create", () => { expect(stderr).toContain("The CLI did not modify its IAM policies"); }); }); + +describe("gateway update role policy warnings", () => { + test("warns before updating a Gateway with an unknown associated role", async () => { + const core = new TestCoreClient(); + const roleArn = "arn:aws:iam::123456789012:role/CustomerCdkGatewayRole"; + core.gateway.getGatewayRolePolicyWarning = async () => ({ + reason: "unknown-role", + roleArn, + }); + + const { stderr } = await run( + ["gateway", "update", "--id", GATEWAY_ID, "--description", "after"], + core, + ); + + expect(stderr).toContain(`Execution role ${roleArn} is not recognized`); + expect(stderr).toContain("The CLI will not modify its IAM policies"); + }); + + test("identifies an explicitly supplied update role as customer-managed", async () => { + const roleArn = "arn:aws:iam::123456789012:role/CustomerGatewayRole"; + + const { stderr } = await run([ + "gateway", + "update", + "--id", + GATEWAY_ID, + "--description", + "after", + "--role-arn", + roleArn, + ]); + + expect(stderr).toContain( + `Using customer-managed execution role ${roleArn}; IAM policies will not be modified.`, + ); + }); + + test("skip-role-policy-update suppresses unknown-role preflight", async () => { + const core = new TestCoreClient(); + core.gateway.getGatewayRolePolicyWarning = async () => { + throw new Error("role warning preflight must be skipped"); + }; + + const { stderr } = await run( + [ + "gateway", + "target", + "update", + "--gateway-id", + GATEWAY_ID, + "--target-id", + TARGET_ID, + "--description", + "after", + "--skip-role-policy-update", + ], + core, + ); + + expect(stderr).not.toContain("Execution role"); + }); +}); diff --git a/src/handlers/gateway/gateway.update.test.tsx b/src/handlers/gateway/gateway.update.test.tsx new file mode 100644 index 000000000..4c7ce7d06 --- /dev/null +++ b/src/handlers/gateway/gateway.update.test.tsx @@ -0,0 +1,663 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + CreateGatewayCommand, + CreateGatewayRuleCommand, + CreateGatewayTargetCommand, + DeleteGatewayCommand, + DeleteGatewayRuleCommand, + DeleteGatewayTargetCommand, + GetGatewayCommand, + GetGatewayRuleCommand, + GetGatewayTargetCommand, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + PutRolePolicyCommand, +} from "@aws-sdk/client-iam"; +import { CoreClient } from "../../core"; +import { createControlClient, createIamClient } from "../../core/factories"; +import { ExecutionRoleManager } from "../../core/executionRoleManager"; +import { + createSilentLogger, + fixtureFactories, + isRecording, + matchGolden, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; +import { createRootHandler } from "../index"; + +async function runWithTestCore(args: string[]): Promise { + const core = new TestCoreClient(); + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", ...args, "--region", "us-west-2"]); + return core; +} + +describe("Gateway update command hierarchy", () => { + test("registers every update leaf", () => { + const root = createRootHandler(new TestCoreClient(), { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + const gateway = root.children().find((child) => child.name() === "gateway")!; + + expect(gateway.children().map((child) => child.name())).toContain("update"); + for (const name of ["target", "connector", "rule"]) { + expect( + gateway + .children() + .find((child) => child.name() === name)! + .children() + .map((child) => child.name()), + ).toContain("update"); + } + }); +}); + +describe("Gateway update validation", () => { + test.each([ + ["Gateway selector", ["gateway", "update", "--description", "after"], /--id/], + ["Gateway mutation", ["gateway", "update", "--id", "gateway-1"], /at least one/], + [ + "Gateway description conflict", + ["gateway", "update", "--id", "gateway-1", "--description", "after", "--clear-description"], + /mutually exclusive/, + ], + [ + "Gateway Policy Engine conflict", + [ + "gateway", + "update", + "--id", + "gateway-1", + "--clear-policy-engine", + "--policy-engine-mode", + "enforce", + ], + /conflicts/, + ], + ["Target selector", ["gateway", "target", "update", "--name", "after"], /--gateway-id/], + [ + "Target mutation", + ["gateway", "target", "update", "--gateway-id", "gateway-1", "--target-id", "target-1"], + /at least one/, + ], + [ + "Target configuration conflict", + [ + "gateway", + "target", + "update", + "--gateway-id", + "gateway-1", + "--target-id", + "target-1", + "--endpoint", + "https://example.test/mcp", + "--target-configuration", + "{}", + ], + /mutually exclusive/, + ], + [ + "Connector selector", + ["gateway", "connector", "update", "--connector", "web-search"], + /--gateway-id/, + ], + [ + "Connector mutation", + ["gateway", "connector", "update", "--gateway-id", "gateway-1", "--id", "target-1"], + /at least one/, + ], + ["Rule selector", ["gateway", "rule", "update", "--priority", "20"], /--gateway-id/], + [ + "Rule mutation", + ["gateway", "rule", "update", "--gateway-id", "gateway-1", "--rule-id", "rule-1"], + /at least one/, + ], + ] as const)("rejects invalid %s input", async (_name, args, error) => { + await expect(runWithTestCore([...args])).rejects.toThrow(error); + }); +}); + +describe("Gateway update patch mapping", () => { + test("maps Gateway set and clear flags", async () => { + const core = await runWithTestCore([ + "gateway", + "update", + "--id", + "gateway-1", + "--description", + "after", + "--clear-protocol", + "--policy-engine-mode", + "enforce", + "--clear-exception-level", + "--skip-role-policy-update", + ]); + + expect(core.gateway.calls.find((call) => call.method === "updateGateway")?.args[0]).toEqual({ + id: "gateway-1", + description: "after", + clearProtocol: true, + policyEngineConfiguration: { mode: "ENFORCE" }, + exceptionLevel: null, + skipRolePolicyUpdate: true, + }); + }); + + test("maps Target replacement and clear flags", async () => { + const core = await runWithTestCore([ + "gateway", + "target", + "update", + "--gateway-id", + "gateway-1", + "--target-id", + "target-1", + "--target-configuration", + '{"http":{"passthrough":{"endpoint":"https://example.test","protocolType":"CUSTOM"}}}', + "--clear-description", + "--clear-credential-provider-configurations", + "--skip-role-policy-update", + ]); + + expect( + core.gateway.calls.find((call) => call.method === "updateGatewayTarget")?.args[0], + ).toEqual({ + gatewayId: "gateway-1", + targetId: "target-1", + description: null, + targetConfiguration: { + http: { passthrough: { endpoint: "https://example.test", protocolType: "CUSTOM" } }, + }, + credentialProviderConfigurations: null, + skipRolePolicyUpdate: true, + }); + }); + + test("maps a curated Connector replacement", async () => { + const core = await runWithTestCore([ + "gateway", + "connector", + "update", + "--gateway-id", + "gateway-1", + "--id", + "target-1", + "--connector", + "web-search", + "--skip-role-policy-update", + ]); + + expect( + core.gateway.calls.find((call) => call.method === "updateGatewayConnector")?.args[0], + ).toEqual({ + gatewayId: "gateway-1", + targetId: "target-1", + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "web-search" }, + configurations: [ + { + name: "WebSearch", + parameterValues: { maxResults: 10 }, + }, + ], + }, + }, + }, + skipRolePolicyUpdate: true, + }); + }); + + test("maps Rule PATCH fields", async () => { + const core = await runWithTestCore([ + "gateway", + "rule", + "update", + "--gateway-id", + "gateway-1", + "--rule-id", + "rule-1", + "--priority", + "20", + "--clear-conditions", + "--description", + "after", + ]); + + expect(core.gateway.calls.find((call) => call.method === "updateGatewayRule")?.args[0]).toEqual( + { + gatewayIdentifier: "gateway-1", + ruleId: "rule-1", + priority: 20, + conditions: [], + description: "after", + }, + ); + }); +}); + +const REGION = "us-east-1"; +const FIXTURES = join(import.meta.dir, "__fixtures__", "update"); +const RESOURCE_STATE = join(FIXTURES, "resources.json"); +const GATEWAY_NAME = "agentcore-cli-gateway-update-fixture"; +const ROLE_NAME = "AgentCoreCliGateway-agentcore-cli-gateway-update-fixture"; +const POLICY_NAME = ExecutionRoleManager.generatedPolicyName("gateway", { + accountId: "603141041947", + region: REGION, + stableResourceKey: ROLE_NAME, +}); +const HTTP_TARGET_NAME = "http-update-fixture"; +const CONNECTOR_TARGET_NAME = "web-search-update-fixture"; +const FLOW_TIMEOUT = 600_000; + +type FixtureState = { + gatewayId: string; + gatewayArn: string; + targetId: string; + connectorId: string; + ruleId: string; +}; + +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient, createLogsClient } = + fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + gatewayOptions: isRecording() + ? undefined + : { + policyUpdater: { + propagationDelayMs: 0, + retryDelayMs: 0, + }, + waitDelayMs: 0, + }, + }); +} + +async function runFixture(args: string[]): Promise { + const io = testIO(); + const root = createRootHandler(createFixtureCore(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return io.stdout(); +} + +async function waitUntil( + operation: () => Promise, + done: (response: T) => boolean, +): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + const response = await operation(); + if (done(response)) return response; + await Bun.sleep(2_000); + } + throw new Error("Timed out waiting for fixture resource state"); +} + +async function ignoreMissing(operation: () => Promise): Promise { + try { + await operation(); + } catch (error) { + if (!["ResourceNotFoundException", "NoSuchEntityException"].includes((error as Error).name)) { + throw error; + } + } +} + +async function waitUntilMissing(operation: () => Promise): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + await operation(); + } catch (error) { + if ((error as Error).name === "ResourceNotFoundException") return; + throw error; + } + await Bun.sleep(2_000); + } + throw new Error("Timed out waiting for fixture resource deletion"); +} + +// Prerequisites use direct clients so recording Update never rewrites Create-owned fixtures. +async function setup(): Promise { + const control = createControlClient({ region: REGION }); + const iam = createIamClient({ region: REGION }); + const role = 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 (!role.Role?.Arn) throw new Error("IAM did not return the fixture role ARN"); + + const gateway = await control.send( + new CreateGatewayCommand({ + name: GATEWAY_NAME, + roleArn: role.Role.Arn, + authorizerType: "NONE", + description: "Gateway before update", + }), + ); + if (!gateway.gatewayId || !gateway.gatewayArn) { + throw new Error("CreateGateway did not return fixture identifiers"); + } + await waitUntil( + () => control.send(new GetGatewayCommand({ gatewayIdentifier: gateway.gatewayId })), + (response) => response.status === "READY", + ); + + await iam.send( + new PutRolePolicyCommand({ + RoleName: ROLE_NAME, + PolicyName: POLICY_NAME, + PolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeGateway", + Resource: gateway.gatewayArn, + }, + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeWebSearch", + Resource: "arn:aws:bedrock-agentcore:us-east-1:aws:tool/web-search.v1", + }, + ], + }), + }), + ); + + const target = await control.send( + new CreateGatewayTargetCommand({ + gatewayIdentifier: gateway.gatewayId, + name: HTTP_TARGET_NAME, + description: "Target before update", + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://example.com", + protocolType: "CUSTOM", + }, + }, + }, + }), + ); + const connector = await control.send( + new CreateGatewayTargetCommand({ + gatewayIdentifier: gateway.gatewayId, + name: CONNECTOR_TARGET_NAME, + description: "Connector before update", + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "web-search" }, + configurations: [ + { + name: "WebSearch", + parameterValues: { maxResults: 10 }, + }, + ], + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }), + ); + if (!target.targetId || !connector.targetId) { + throw new Error("CreateGatewayTarget did not return fixture identifiers"); + } + await Promise.all( + [target.targetId, connector.targetId].map((targetId) => + waitUntil( + () => + control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: gateway.gatewayId, + targetId, + }), + ), + (response) => response.status === "READY", + ), + ), + ); + + const rule = await control.send( + new CreateGatewayRuleCommand({ + gatewayIdentifier: gateway.gatewayId, + priority: 10, + actions: [ + { + routeToTarget: { + staticRoute: { + targetName: HTTP_TARGET_NAME, + }, + }, + }, + ], + description: "Rule before update", + }), + ); + if (!rule.ruleId) throw new Error("CreateGatewayRule did not return the fixture rule ID"); + await waitUntil( + () => + control.send( + new GetGatewayRuleCommand({ + gatewayIdentifier: gateway.gatewayId, + ruleId: rule.ruleId, + }), + ), + (response) => response.status === "ACTIVE", + ); + + const state = { + gatewayId: gateway.gatewayId, + gatewayArn: gateway.gatewayArn, + targetId: target.targetId, + connectorId: connector.targetId, + ruleId: rule.ruleId, + }; + mkdirSync(FIXTURES, { recursive: true }); + writeFileSync(RESOURCE_STATE, `${JSON.stringify(state, null, 2)}\n`); + return state; +} + +async function cleanup(state: FixtureState): Promise { + const control = createControlClient({ region: REGION }); + const iam = createIamClient({ region: REGION }); + await ignoreMissing(() => + control.send( + new DeleteGatewayRuleCommand({ + gatewayIdentifier: state.gatewayId, + ruleId: state.ruleId, + }), + ), + ); + await waitUntilMissing(() => + control.send( + new GetGatewayRuleCommand({ + gatewayIdentifier: state.gatewayId, + ruleId: state.ruleId, + }), + ), + ); + for (const targetId of [state.targetId, state.connectorId]) { + await ignoreMissing(() => + control.send( + new DeleteGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId, + }), + ), + ); + await waitUntilMissing(() => + control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId, + }), + ), + ); + } + await ignoreMissing(() => + control.send(new DeleteGatewayCommand({ gatewayIdentifier: state.gatewayId })), + ); + await waitUntilMissing(() => + control.send(new GetGatewayCommand({ gatewayIdentifier: state.gatewayId })), + ); + await ignoreMissing(() => + iam.send(new DeleteRolePolicyCommand({ RoleName: ROLE_NAME, PolicyName: POLICY_NAME })), + ); + await ignoreMissing(() => iam.send(new DeleteRoleCommand({ RoleName: ROLE_NAME }))); +} + +async function verify( + operation: () => Promise, + done: (response: T) => boolean, +): Promise { + if (!isRecording()) return; + await waitUntil(operation, done); +} + +test( + "updates a Gateway, Target, Connector, and Rule through the real Core", + async () => { + const state = isRecording() + ? await setup() + : (JSON.parse(readFileSync(RESOURCE_STATE, "utf8")) as FixtureState); + const control = createControlClient({ region: REGION }); + + try { + const gatewayStdout = await runFixture([ + "gateway", + "update", + "--id", + state.gatewayId, + "--description", + "Gateway after update", + ]); + matchGolden(FIXTURES, "gateway-update.golden.json", gatewayStdout); + expect(JSON.parse(gatewayStdout).description).toBe("Gateway after update"); + await verify( + () => control.send(new GetGatewayCommand({ gatewayIdentifier: state.gatewayId })), + (response) => + response.status === "READY" && response.description === "Gateway after update", + ); + + const targetStdout = await runFixture([ + "gateway", + "target", + "update", + "--gateway-id", + state.gatewayId, + "--target-id", + state.targetId, + "--description", + "Target after update", + ]); + matchGolden(FIXTURES, "target-update.golden.json", targetStdout); + expect(JSON.parse(targetStdout).description).toBe("Target after update"); + await verify( + () => + control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId: state.targetId, + }), + ), + (response) => response.status === "READY" && response.description === "Target after update", + ); + + const connectorStdout = await runFixture([ + "gateway", + "connector", + "update", + "--gateway-id", + state.gatewayId, + "--id", + state.connectorId, + "--description", + "Connector after update", + ]); + matchGolden(FIXTURES, "connector-update.golden.json", connectorStdout); + expect(JSON.parse(connectorStdout).description).toBe("Connector after update"); + await verify( + () => + control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId: state.connectorId, + }), + ), + (response) => + response.status === "READY" && response.description === "Connector after update", + ); + + const ruleStdout = await runFixture([ + "gateway", + "rule", + "update", + "--gateway-id", + state.gatewayId, + "--rule-id", + state.ruleId, + "--priority", + "20", + "--description", + "Rule after update", + ]); + matchGolden(FIXTURES, "rule-update.golden.json", ruleStdout); + const updatedRule = JSON.parse(ruleStdout); + expect(updatedRule.priority).toBe(20); + expect(updatedRule.description).toBe("Rule after update"); + await verify( + () => + control.send( + new GetGatewayRuleCommand({ + gatewayIdentifier: state.gatewayId, + ruleId: state.ruleId, + }), + ), + (response) => + response.status === "ACTIVE" && + response.priority === 20 && + response.description === "Rule after update", + ); + } finally { + if (isRecording()) await cleanup(state); + } + }, + FLOW_TIMEOUT, +); diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index dbbf51c3b..d370ae5e7 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -5,10 +5,12 @@ import { Router } from "../../router"; import type { Core } from "../types"; import { createGatewayConnectorHandler } from "./connector"; import { createCreateGatewayHandler } from "./create"; +import { createDeleteGatewayHandler } from "./delete"; import { createGetGatewayHandler } from "./get"; import { createListGatewaysHandler } from "./list"; import { createGatewayRuleHandler } from "./rule"; import { createGatewayTargetHandler } from "./target"; +import { createUpdateGatewayHandler } from "./update"; export function createGatewayHandler(core: Core, io: AppIO): Router { return new Router("gateway", "inspect AgentCore Gateways") @@ -16,8 +18,10 @@ export function createGatewayHandler(core: Core, io: AppIO): Router { .default(renderTui(core, io)) .supportedTuiCommands("get", "list", "target", "connector", "rule") .handler(createCreateGatewayHandler(core, io)) + .handler(createUpdateGatewayHandler(core, io)) .handler(createGetGatewayHandler(core)) .handler(createListGatewaysHandler(core)) + .handler(createDeleteGatewayHandler(core)) .handler(createGatewayTargetHandler(core, io)) .handler(createGatewayConnectorHandler(core, io)) .handler(createGatewayRuleHandler(core, io)); diff --git a/src/handlers/gateway/rolePolicyWarning.ts b/src/handlers/gateway/rolePolicyWarning.ts new file mode 100644 index 000000000..e14f0f728 --- /dev/null +++ b/src/handlers/gateway/rolePolicyWarning.ts @@ -0,0 +1,32 @@ +import { warn, type AppIO } from "../../io"; +import type { CoreOptions } from "../../core/types"; +import type { Core } from "../types"; + +export async function warnForGatewayRolePolicyUpdate( + core: Core, + io: AppIO, + gatewayId: string, + options: CoreOptions, + input: { + explicitRoleArn?: string; + skipRolePolicyUpdate?: boolean; + }, +): Promise { + if (input.skipRolePolicyUpdate) return; + if (input.explicitRoleArn) { + warn( + io, + `Using customer-managed execution role ${input.explicitRoleArn}; IAM policies will not be modified.`, + ); + return; + } + + const warning = await core.gateway.getGatewayRolePolicyWarning(gatewayId, options); + if (!warning) return; + warn( + io, + `Execution role ${warning.roleArn} is not recognized as AgentCore CLI or console managed. ` + + "The CLI will not modify its IAM policies. " + + "You are responsible for permissions required by this update.", + ); +} diff --git a/src/handlers/gateway/rule/delete/index.tsx b/src/handlers/gateway/rule/delete/index.tsx new file mode 100644 index 000000000..b79ec0eaa --- /dev/null +++ b/src/handlers/gateway/rule/delete/index.tsx @@ -0,0 +1,33 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createDeleteGatewayRuleHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a Gateway Rule", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag("rule-id", "the Rule ID", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (!flags["rule-id"]) { + throw new InputValidationError("required option '--rule-id ' not specified"); + } + ctx + .require(JsonRendererKey) + .renderJson( + await core.gateway.deleteGatewayRule( + flags["gateway-id"], + flags["rule-id"], + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/gateway/rule/index.tsx b/src/handlers/gateway/rule/index.tsx index e8ee9ad68..45d73bb23 100644 --- a/src/handlers/gateway/rule/index.tsx +++ b/src/handlers/gateway/rule/index.tsx @@ -3,14 +3,18 @@ import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; import { createCreateGatewayRuleHandler } from "./create"; +import { createDeleteGatewayRuleHandler } from "./delete"; import { createGetGatewayRuleHandler } from "./get"; import { createListGatewayRulesHandler } from "./list"; +import { createUpdateGatewayRuleHandler } from "./update"; export function createGatewayRuleHandler(core: Core, io: AppIO): Router { return new Router("rule", "inspect rules for an AgentCore Gateway") .default(renderTui(core, io)) .supportedTuiCommands("get", "list") .handler(createCreateGatewayRuleHandler(core, io)) + .handler(createUpdateGatewayRuleHandler(core, io)) .handler(createGetGatewayRuleHandler(core)) - .handler(createListGatewayRulesHandler(core)); + .handler(createListGatewayRulesHandler(core)) + .handler(createDeleteGatewayRuleHandler(core)); } diff --git a/src/handlers/gateway/rule/update/index.tsx b/src/handlers/gateway/rule/update/index.tsx new file mode 100644 index 000000000..4f93fb452 --- /dev/null +++ b/src/handlers/gateway/rule/update/index.tsx @@ -0,0 +1,82 @@ +import type { Action, Condition } from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { assertMutuallyExclusiveInputs, coreOptsFromCtx, parseJsonArrayFlag } from "../../../utils"; +import type { GatewayRuleUpdateInput } from "../../types"; + +export const createUpdateGatewayRuleHandler = (core: Core, io: AppIO) => + createHandler({ + name: "update", + description: "update a Gateway Rule", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag("rule-id", "the Rule ID", z.string().optional()), + flag( + "priority", + "updated priority from 1 to 1000000", + z.number().int().min(1).max(1_000_000).optional(), + ), + flag( + "conditions", + "replacement conditions (JSON array; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag("clear-conditions", "make the Rule unconditional", z.boolean()), + flag( + "actions", + "replacement actions (JSON array; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag("description", "updated Rule description", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (!flags["rule-id"]) { + throw new InputValidationError("required option '--rule-id ' not specified"); + } + assertMutuallyExclusiveInputs([ + [ + "conditions", + flags.conditions, + "clear-conditions", + flags["clear-conditions"] || undefined, + ], + ]); + if (flags.description === "") { + throw new InputValidationError("Rule description cannot be empty or cleared"); + } + const source = new SourceResolver({ stdin: io.stdin }); + const conditions = parseJsonArrayFlag( + "conditions", + await source.resolveText("conditions", flags.conditions), + ); + const actions = parseJsonArrayFlag( + "actions", + await source.resolveText("actions", flags.actions), + ); + const mutations: Omit = { + priority: flags.priority, + conditions: flags["clear-conditions"] ? [] : conditions, + actions, + description: flags.description, + }; + if (Object.values(mutations).every((value) => value === undefined)) { + throw new InputValidationError("Rule update requires at least one mutation option"); + } + const input: GatewayRuleUpdateInput = { + gatewayIdentifier: flags["gateway-id"], + ruleId: flags["rule-id"], + ...mutations, + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.gateway.updateGatewayRule(input, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/gateway/target/delete/index.tsx b/src/handlers/gateway/target/delete/index.tsx new file mode 100644 index 000000000..75c3626fb --- /dev/null +++ b/src/handlers/gateway/target/delete/index.tsx @@ -0,0 +1,33 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createDeleteGatewayTargetHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a Gateway Target", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag("target-id", "the Target ID", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (!flags["target-id"]) { + throw new InputValidationError("required option '--target-id ' not specified"); + } + ctx + .require(JsonRendererKey) + .renderJson( + await core.gateway.deleteGatewayTarget( + flags["gateway-id"], + flags["target-id"], + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/gateway/target/index.tsx b/src/handlers/gateway/target/index.tsx index 8f21577e1..f770a088b 100644 --- a/src/handlers/gateway/target/index.tsx +++ b/src/handlers/gateway/target/index.tsx @@ -3,14 +3,18 @@ import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; import { createCreateGatewayTargetHandler } from "./create"; +import { createDeleteGatewayTargetHandler } from "./delete"; import { createGetGatewayTargetHandler } from "./get"; import { createListGatewayTargetsHandler } from "./list"; +import { createUpdateGatewayTargetHandler } from "./update"; export function createGatewayTargetHandler(core: Core, io: AppIO): Router { return new Router("target", "inspect targets for an AgentCore Gateway") .default(renderTui(core, io)) .supportedTuiCommands("get", "list") .handler(createCreateGatewayTargetHandler(core, io)) + .handler(createUpdateGatewayTargetHandler(core, io)) .handler(createGetGatewayTargetHandler(core)) - .handler(createListGatewayTargetsHandler(core)); + .handler(createListGatewayTargetsHandler(core)) + .handler(createDeleteGatewayTargetHandler(core)); } diff --git a/src/handlers/gateway/target/update/index.tsx b/src/handlers/gateway/target/update/index.tsx new file mode 100644 index 000000000..51f64e9c0 --- /dev/null +++ b/src/handlers/gateway/target/update/index.tsx @@ -0,0 +1,144 @@ +import type { + CredentialProviderConfiguration, + MetadataConfiguration, + PrivateEndpoint, + TargetConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { + assertMutuallyExclusiveInputs, + coreOptsFromCtx, + parseJsonArrayFlag, + parseJsonObjectFlag, +} from "../../../utils"; +import type { GatewayTargetUpdatePatch } from "../../types"; +import { warnForGatewayRolePolicyUpdate } from "../../rolePolicyWarning"; + +export const createUpdateGatewayTargetHandler = (core: Core, io: AppIO) => + createHandler({ + name: "update", + description: "update a Gateway Target", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag("target-id", "the Target ID", z.string().optional()), + flag("name", "updated Target name", z.string().optional()), + flag("description", "updated Target description", z.string().optional()), + flag("endpoint", "updated endpoint for an existing MCP server Target", z.string().optional()), + flag( + "target-configuration", + "complete replacement Target configuration (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "credential-provider-configurations", + "replacement outbound credentials (JSON array; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "metadata-configuration", + "replacement metadata propagation (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "private-endpoint", + "replacement private endpoint (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag("clear-description", "remove the Target description", z.boolean()), + flag("clear-credential-provider-configurations", "remove outbound credentials", z.boolean()), + flag("clear-metadata-configuration", "remove metadata propagation", z.boolean()), + flag("clear-private-endpoint", "remove private endpoint configuration", z.boolean()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (!flags["target-id"]) { + throw new InputValidationError("required option '--target-id ' not specified"); + } + + assertMutuallyExclusiveInputs([ + [ + "description", + flags.description, + "clear-description", + flags["clear-description"] || undefined, + ], + [ + "credential-provider-configurations", + flags["credential-provider-configurations"], + "clear-credential-provider-configurations", + flags["clear-credential-provider-configurations"] || undefined, + ], + [ + "metadata-configuration", + flags["metadata-configuration"], + "clear-metadata-configuration", + flags["clear-metadata-configuration"] || undefined, + ], + [ + "private-endpoint", + flags["private-endpoint"], + "clear-private-endpoint", + flags["clear-private-endpoint"] || undefined, + ], + ["endpoint", flags.endpoint, "target-configuration", flags["target-configuration"]], + ]); + + const source = new SourceResolver({ stdin: io.stdin }); + const targetConfiguration = parseJsonObjectFlag( + "target-configuration", + await source.resolveText("target-configuration", flags["target-configuration"]), + ); + const credentialProviderConfigurations = parseJsonArrayFlag( + "credential-provider-configurations", + await source.resolveText( + "credential-provider-configurations", + flags["credential-provider-configurations"], + ), + ); + const metadataConfiguration = parseJsonObjectFlag( + "metadata-configuration", + await source.resolveText("metadata-configuration", flags["metadata-configuration"]), + ); + const privateEndpoint = parseJsonObjectFlag( + "private-endpoint", + await source.resolveText("private-endpoint", flags["private-endpoint"]), + ); + + const mutations: Omit = { + name: flags.name, + description: flags["clear-description"] ? null : flags.description, + endpoint: flags.endpoint, + targetConfiguration, + credentialProviderConfigurations: flags["clear-credential-provider-configurations"] + ? null + : credentialProviderConfigurations, + metadataConfiguration: flags["clear-metadata-configuration"] ? null : metadataConfiguration, + privateEndpoint: flags["clear-private-endpoint"] ? null : privateEndpoint, + }; + if (Object.values(mutations).every((value) => value === undefined)) { + throw new InputValidationError("Target update requires at least one mutation option"); + } + const patch: GatewayTargetUpdatePatch = { + gatewayId: flags["gateway-id"], + targetId: flags["target-id"], + ...mutations, + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), + }; + + const options = coreOptsFromCtx(ctx); + await warnForGatewayRolePolicyUpdate(core, io, flags["gateway-id"], options, { + skipRolePolicyUpdate: flags["skip-role-policy-update"], + }); + ctx + .require(JsonRendererKey) + .renderJson(await core.gateway.updateGatewayTarget(patch, options)); + }, + }); diff --git a/src/handlers/gateway/types.tsx b/src/handlers/gateway/types.tsx index ab1675b93..a32197c97 100644 --- a/src/handlers/gateway/types.tsx +++ b/src/handlers/gateway/types.tsx @@ -5,12 +5,21 @@ import type { CreateGatewayRuleResponse, CreateGatewayTargetRequest, CreateGatewayTargetResponse, + DeleteGatewayResponse, + DeleteGatewayRuleResponse, + DeleteGatewayTargetResponse, GetGatewayResponse, GetGatewayRuleResponse, GetGatewayTargetResponse, ListGatewayRulesResponse, ListGatewaysResponse, ListGatewayTargetsResponse, + UpdateGatewayRequest, + UpdateGatewayResponse, + UpdateGatewayRuleRequest, + UpdateGatewayRuleResponse, + UpdateGatewayTargetRequest, + UpdateGatewayTargetResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { CoreOptions } from "../../core/types"; @@ -34,18 +43,53 @@ export type GatewayMutationResult = { rolePolicyWarning?: GatewayRolePolicyWarning; }; +export type GatewayUpdatePatch = { + id: string; + roleArn?: UpdateGatewayRequest["roleArn"]; + skipRolePolicyUpdate?: boolean; + clearProtocol?: boolean; + description?: UpdateGatewayRequest["description"] | null; + protocolConfiguration?: UpdateGatewayRequest["protocolConfiguration"] | null; + authorizerConfiguration?: UpdateGatewayRequest["authorizerConfiguration"]; + customTransformConfiguration?: UpdateGatewayRequest["customTransformConfiguration"] | null; + interceptorConfigurations?: UpdateGatewayRequest["interceptorConfigurations"] | null; + policyEngineConfiguration?: Partial< + NonNullable + > | null; + exceptionLevel?: UpdateGatewayRequest["exceptionLevel"] | null; + wafConfiguration?: UpdateGatewayRequest["wafConfiguration"] | null; +}; + +export type GatewayTargetUpdatePatch = { + gatewayId: string; + targetId: string; + skipRolePolicyUpdate?: boolean; + name?: UpdateGatewayTargetRequest["name"]; + description?: UpdateGatewayTargetRequest["description"] | null; + endpoint?: string; + targetConfiguration?: UpdateGatewayTargetRequest["targetConfiguration"]; + credentialProviderConfigurations?: + UpdateGatewayTargetRequest["credentialProviderConfigurations"] | null; + metadataConfiguration?: UpdateGatewayTargetRequest["metadataConfiguration"] | null; + privateEndpoint?: UpdateGatewayTargetRequest["privateEndpoint"] | null; +}; + +export type GatewayRuleUpdateInput = UpdateGatewayRuleRequest; + export interface CoreGatewayClient { getGatewayRolePolicyWarning( gatewayId: string, options: CoreOptions, ): Promise; createGateway(input: CreateGatewayInput, options: CoreOptions): Promise; + updateGateway(patch: GatewayUpdatePatch, options: CoreOptions): Promise; getGateway(id: string, options: CoreOptions): Promise; listGateways( nextToken: string | undefined, maxResults: number | undefined, options: CoreOptions, ): Promise; + deleteGateway(id: string, options: CoreOptions): Promise; getGatewayTarget( gatewayId: string, targetId: string, @@ -72,6 +116,19 @@ export interface CoreGatewayClient { maxResults: number | undefined, options: CoreOptions, ): Promise; + updateGatewayTarget( + patch: GatewayTargetUpdatePatch, + options: CoreOptions, + ): Promise; + updateGatewayConnector( + patch: GatewayTargetUpdatePatch, + options: CoreOptions, + ): Promise; + deleteGatewayTarget( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise; getGatewayRule( gatewayId: string, ruleId: string, @@ -87,4 +144,13 @@ export interface CoreGatewayClient { input: CreateGatewayRuleInput, options: CoreOptions, ): Promise; + updateGatewayRule( + input: GatewayRuleUpdateInput, + options: CoreOptions, + ): Promise; + deleteGatewayRule( + gatewayId: string, + ruleId: string, + options: CoreOptions, + ): Promise; } diff --git a/src/handlers/gateway/update/index.tsx b/src/handlers/gateway/update/index.tsx new file mode 100644 index 000000000..6afbf28a0 --- /dev/null +++ b/src/handlers/gateway/update/index.tsx @@ -0,0 +1,205 @@ +import type { + AuthorizerConfiguration, + CustomTransformConfiguration, + GatewayInterceptorConfiguration, + GatewayProtocolConfiguration, + WafConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../errors"; +import { type AppIO, SourceResolver } from "../../../io"; +import { createHandler, flag } from "../../../router"; +import { JsonRendererKey } from "../../../tui"; +import type { Core } from "../../types"; +import { + assertMutuallyExclusiveInputs, + coreOptsFromCtx, + parseJsonArrayFlag, + parseJsonObjectFlag, +} from "../../utils"; +import type { GatewayUpdatePatch } from "../types"; +import { warnForGatewayRolePolicyUpdate } from "../rolePolicyWarning"; + +export const createUpdateGatewayHandler = (core: Core, io: AppIO) => + createHandler({ + name: "update", + description: "update an AgentCore Gateway", + flags: [ + flag("id", "the Gateway ID", z.string().optional()), + flag("role-arn", "updated IAM role ARN", z.string().optional()), + flag("description", "updated Gateway description", z.string().optional()), + flag( + "protocol-configuration", + "replacement MCP protocol configuration (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "authorizer-configuration", + "replacement CUSTOM_JWT configuration (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "custom-transform-configuration", + "replacement custom transform configuration (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "interceptor-configurations", + "replacement interceptors (JSON array; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag("policy-engine-arn", "Policy Engine ARN", z.string().optional()), + flag( + "policy-engine-mode", + "Policy Engine mode: log-only or enforce", + z.enum(["log-only", "enforce"]).optional(), + ), + flag("exception-level", "exception detail level: debug", z.enum(["debug"]).optional()), + flag( + "waf-configuration", + "replacement WAF configuration (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag("clear-protocol", "remove the MCP-only Target restriction", z.boolean()), + flag("clear-description", "remove the Gateway description", z.boolean()), + flag("clear-protocol-configuration", "remove MCP protocol overrides", z.boolean()), + flag( + "clear-custom-transform-configuration", + "remove the custom transform configuration", + z.boolean(), + ), + flag("clear-interceptor-configurations", "remove every interceptor", z.boolean()), + flag("clear-policy-engine", "detach the Policy Engine", z.boolean()), + flag("clear-exception-level", "return to generic invocation errors", z.boolean()), + flag("clear-waf-configuration", "reset WAF failure mode to FAIL_CLOSE", z.boolean()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), + ], + handle: async (ctx, flags) => { + if (!flags.id) { + throw new InputValidationError("required option '--id ' not specified"); + } + + assertMutuallyExclusiveInputs([ + [ + "description", + flags.description, + "clear-description", + flags["clear-description"] || undefined, + ], + [ + "protocol-configuration", + flags["protocol-configuration"], + "clear-protocol-configuration", + flags["clear-protocol-configuration"] || undefined, + ], + [ + "custom-transform-configuration", + flags["custom-transform-configuration"], + "clear-custom-transform-configuration", + flags["clear-custom-transform-configuration"] || undefined, + ], + [ + "interceptor-configurations", + flags["interceptor-configurations"], + "clear-interceptor-configurations", + flags["clear-interceptor-configurations"] || undefined, + ], + [ + "exception-level", + flags["exception-level"], + "clear-exception-level", + flags["clear-exception-level"] || undefined, + ], + [ + "waf-configuration", + flags["waf-configuration"], + "clear-waf-configuration", + flags["clear-waf-configuration"] || undefined, + ], + ]); + if ( + flags["clear-policy-engine"] && + (flags["policy-engine-arn"] !== undefined || flags["policy-engine-mode"] !== undefined) + ) { + throw new InputValidationError( + "--clear-policy-engine conflicts with --policy-engine-arn and --policy-engine-mode", + ); + } + if (flags["policy-engine-arn"] && !flags["policy-engine-mode"]) { + throw new InputValidationError("--policy-engine-arn requires --policy-engine-mode"); + } + + const source = new SourceResolver({ stdin: io.stdin }); + const protocolConfiguration = parseJsonObjectFlag( + "protocol-configuration", + await source.resolveText("protocol-configuration", flags["protocol-configuration"]), + ); + const authorizerConfiguration = parseJsonObjectFlag( + "authorizer-configuration", + await source.resolveText("authorizer-configuration", flags["authorizer-configuration"]), + ); + const customTransformConfiguration = parseJsonObjectFlag( + "custom-transform-configuration", + await source.resolveText( + "custom-transform-configuration", + flags["custom-transform-configuration"], + ), + ); + const interceptorConfigurations = parseJsonArrayFlag( + "interceptor-configurations", + await source.resolveText("interceptor-configurations", flags["interceptor-configurations"]), + ); + const wafConfiguration = parseJsonObjectFlag( + "waf-configuration", + await source.resolveText("waf-configuration", flags["waf-configuration"]), + ); + + const mutations: Omit = { + roleArn: flags["role-arn"], + clearProtocol: flags["clear-protocol"] || undefined, + description: flags["clear-description"] ? null : flags.description, + protocolConfiguration: flags["clear-protocol-configuration"] ? null : protocolConfiguration, + authorizerConfiguration, + customTransformConfiguration: flags["clear-custom-transform-configuration"] + ? null + : customTransformConfiguration, + interceptorConfigurations: flags["clear-interceptor-configurations"] + ? null + : interceptorConfigurations, + policyEngineConfiguration: flags["clear-policy-engine"] + ? null + : flags["policy-engine-arn"] !== undefined || flags["policy-engine-mode"] !== undefined + ? { + arn: flags["policy-engine-arn"], + mode: + flags["policy-engine-mode"] === undefined + ? undefined + : flags["policy-engine-mode"] === "enforce" + ? "ENFORCE" + : "LOG_ONLY", + } + : undefined, + exceptionLevel: flags["clear-exception-level"] + ? null + : flags["exception-level"] + ? "DEBUG" + : undefined, + wafConfiguration: flags["clear-waf-configuration"] ? null : wafConfiguration, + }; + if (Object.values(mutations).every((value) => value === undefined)) { + throw new InputValidationError("Gateway update requires at least one mutation option"); + } + const patch: GatewayUpdatePatch = { + id: flags.id, + ...mutations, + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), + }; + + const options = coreOptsFromCtx(ctx); + await warnForGatewayRolePolicyUpdate(core, io, flags.id, options, { + explicitRoleArn: flags["role-arn"], + skipRolePolicyUpdate: flags["skip-role-policy-update"], + }); + ctx.require(JsonRendererKey).renderJson(await core.gateway.updateGateway(patch, options)); + }, + }); diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 737f02455..17fc4cec4 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -72,6 +72,21 @@ export function parseJsonArrayFlag(name: string, raw: string | undefined): T[ return parsed as T[]; } +export function assertMutuallyExclusiveInputs( + pairs: readonly (readonly [ + leftName: string, + leftValue: unknown, + rightName: string, + rightValue: unknown, + ])[], +): void { + for (const [leftName, leftValue, rightName, rightValue] of pairs) { + if (leftValue !== undefined && rightValue !== undefined) { + throw new InputValidationError(`--${leftName} and --${rightName} are mutually exclusive`); + } + } +} + // parseTags parses a tags flag that accepts two mutually exclusive forms: // - Repeated key=value shorthand: ["env=prod", "team=foo"] // - A single JSON object: ['{"env":"prod","team":"foo"}'] diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 1a84d503d..2a87db5b5 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -8,6 +8,9 @@ import type { CreateHarnessEndpointResponse, CreateHarnessResponse, DeleteApiKeyCredentialProviderResponse, + DeleteGatewayResponse, + DeleteGatewayRuleResponse, + DeleteGatewayTargetResponse, DeleteOauth2CredentialProviderResponse, DeleteHarnessEndpointRequest, DeleteHarnessEndpointResponse, @@ -54,6 +57,9 @@ import type { UpdateOnlineEvaluationConfigResponse, UpdateApiKeyCredentialProviderResponse, UpdateOauth2CredentialProviderResponse, + UpdateGatewayResponse, + UpdateGatewayRuleResponse, + UpdateGatewayTargetResponse, UpdateHarnessEndpointRequest, UpdateHarnessEndpointResponse, UpdateHarnessRequest, @@ -89,6 +95,10 @@ import type { CreateGatewayInput, CreateGatewayRuleInput, CreateGatewayTargetInput, + GatewayRuleUpdateInput, + GatewayRolePolicyWarning, + GatewayTargetUpdatePatch, + GatewayUpdatePatch, } from "../handlers/gateway/types"; import type { CoreIdentityClient, @@ -177,14 +187,20 @@ const DEFAULT_LIST_MEMORY_RECORDS_RESPONSE: ListMemoryRecordsOutput = { memoryRecordSummaries: [], }; const DEFAULT_CREATE_GATEWAY_RESPONSE = {} as CreateGatewayResponse; +const DEFAULT_UPDATE_GATEWAY_RESPONSE = {} as UpdateGatewayResponse; const DEFAULT_GET_GATEWAY_RESPONSE = {} as GetGatewayResponse; const DEFAULT_LIST_GATEWAYS_RESPONSE: ListGatewaysResponse = { items: [] }; +const DEFAULT_DELETE_GATEWAY_RESPONSE = {} as DeleteGatewayResponse; const DEFAULT_CREATE_GATEWAY_TARGET_RESPONSE = {} as CreateGatewayTargetResponse; +const DEFAULT_UPDATE_GATEWAY_TARGET_RESPONSE = {} as UpdateGatewayTargetResponse; const DEFAULT_GET_GATEWAY_TARGET_RESPONSE = {} as GetGatewayTargetResponse; const DEFAULT_LIST_GATEWAY_TARGETS_RESPONSE: ListGatewayTargetsResponse = { items: [] }; +const DEFAULT_DELETE_GATEWAY_TARGET_RESPONSE = {} as DeleteGatewayTargetResponse; const DEFAULT_CREATE_GATEWAY_RULE_RESPONSE = {} as CreateGatewayRuleResponse; +const DEFAULT_UPDATE_GATEWAY_RULE_RESPONSE = {} as UpdateGatewayRuleResponse; const DEFAULT_GET_GATEWAY_RULE_RESPONSE = {} as GetGatewayRuleResponse; const DEFAULT_LIST_GATEWAY_RULES_RESPONSE: ListGatewayRulesResponse = { gatewayRules: [] }; +const DEFAULT_DELETE_GATEWAY_RULE_RESPONSE = {} as DeleteGatewayRuleResponse; const DEFAULT_CREATE_OAUTH2_RESPONSE = {} as CreateOauth2CredentialProviderResponse; const DEFAULT_GET_OAUTH2_RESPONSE = {} as GetOauth2CredentialProviderResponse; const DEFAULT_LIST_OAUTH2_RESPONSE: ListOauth2CredentialProvidersResponse = { @@ -834,12 +850,16 @@ export class TestGatewayClient implements CoreGatewayClient { private getResponse: GetGatewayResponse = DEFAULT_GET_GATEWAY_RESPONSE; private listResponses = new Map(); + private deleteResponse: DeleteGatewayResponse = DEFAULT_DELETE_GATEWAY_RESPONSE; private getTargetResponse: GetGatewayTargetResponse = DEFAULT_GET_GATEWAY_TARGET_RESPONSE; private listTargetResponses = new Map(); private getConnectorResponse: GetGatewayTargetResponse = DEFAULT_GET_GATEWAY_TARGET_RESPONSE; private listConnectorResponses = new Map(); + private deleteTargetResponse: DeleteGatewayTargetResponse = + DEFAULT_DELETE_GATEWAY_TARGET_RESPONSE; private getRuleResponse: GetGatewayRuleResponse = DEFAULT_GET_GATEWAY_RULE_RESPONSE; private listRuleResponses = new Map(); + private deleteRuleResponse: DeleteGatewayRuleResponse = DEFAULT_DELETE_GATEWAY_RULE_RESPONSE; private error?: Error; setGetResponse(response: GetGatewayResponse): this { @@ -852,6 +872,11 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } + setDeleteResponse(response: DeleteGatewayResponse): this { + this.deleteResponse = response; + return this; + } + setGetTargetResponse(response: GetGatewayTargetResponse): this { this.getTargetResponse = response; return this; @@ -872,6 +897,11 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } + setDeleteTargetResponse(response: DeleteGatewayTargetResponse): this { + this.deleteTargetResponse = response; + return this; + } + setGetRuleResponse(response: GetGatewayRuleResponse): this { this.getRuleResponse = response; return this; @@ -882,12 +912,17 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } + setDeleteRuleResponse(response: DeleteGatewayRuleResponse): this { + this.deleteRuleResponse = response; + return this; + } + setError(error: Error | undefined): this { this.error = error; return this; } - async getGatewayRolePolicyWarning(): Promise { + async getGatewayRolePolicyWarning(): Promise { return undefined; } @@ -900,6 +935,15 @@ export class TestGatewayClient implements CoreGatewayClient { return DEFAULT_CREATE_GATEWAY_RESPONSE; } + async updateGateway( + patch: GatewayUpdatePatch, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "updateGateway", args: [patch, options] }); + if (this.error) throw this.error; + return DEFAULT_UPDATE_GATEWAY_RESPONSE; + } + async getGateway(id: string, options: CoreOptions): Promise { this.calls.push({ method: "getGateway", args: [id, options] }); if (this.error) throw this.error; @@ -920,6 +964,12 @@ export class TestGatewayClient implements CoreGatewayClient { ); } + async deleteGateway(id: string, options: CoreOptions): Promise { + this.calls.push({ method: "deleteGateway", args: [id, options] }); + if (this.error) throw this.error; + return this.deleteResponse; + } + async createGatewayTarget( input: CreateGatewayTargetInput, options: CoreOptions, @@ -929,6 +979,24 @@ export class TestGatewayClient implements CoreGatewayClient { return { response: DEFAULT_CREATE_GATEWAY_TARGET_RESPONSE }; } + async updateGatewayTarget( + patch: GatewayTargetUpdatePatch, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "updateGatewayTarget", args: [patch, options] }); + if (this.error) throw this.error; + return DEFAULT_UPDATE_GATEWAY_TARGET_RESPONSE; + } + + async updateGatewayConnector( + patch: GatewayTargetUpdatePatch, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "updateGatewayConnector", args: [patch, options] }); + if (this.error) throw this.error; + return DEFAULT_UPDATE_GATEWAY_TARGET_RESPONSE; + } + async getGatewayTarget( gatewayId: string, targetId: string, @@ -985,6 +1053,16 @@ export class TestGatewayClient implements CoreGatewayClient { ); } + async deleteGatewayTarget( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "deleteGatewayTarget", args: [gatewayId, targetId, options] }); + if (this.error) throw this.error; + return this.deleteTargetResponse; + } + async createGatewayRule( input: CreateGatewayRuleInput, options: CoreOptions, @@ -994,6 +1072,15 @@ export class TestGatewayClient implements CoreGatewayClient { return DEFAULT_CREATE_GATEWAY_RULE_RESPONSE; } + async updateGatewayRule( + input: GatewayRuleUpdateInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "updateGatewayRule", args: [input, options] }); + if (this.error) throw this.error; + return DEFAULT_UPDATE_GATEWAY_RULE_RESPONSE; + } + async getGatewayRule( gatewayId: string, ruleId: string, @@ -1021,6 +1108,15 @@ export class TestGatewayClient implements CoreGatewayClient { DEFAULT_LIST_GATEWAY_RULES_RESPONSE ); } + async deleteGatewayRule( + gatewayId: string, + ruleId: string, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "deleteGatewayRule", args: [gatewayId, ruleId, options] }); + if (this.error) throw this.error; + return this.deleteRuleResponse; + } } type TestCoreClientOptions = { diff --git a/src/testing/fixtures.test.tsx b/src/testing/fixtures.test.tsx index ba7f13ad0..d3099897b 100644 --- a/src/testing/fixtures.test.tsx +++ b/src/testing/fixtures.test.tsx @@ -2,6 +2,7 @@ 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 { GetGatewayCommand } from "@aws-sdk/client-bedrock-agentcore-control"; import { GetRolePolicyCommand, ListRolePoliciesCommand, @@ -75,3 +76,37 @@ describe("fixture IAM replay", () => { }); }); }); + +describe("fixture temporal replay", () => { + test("replays repeated command responses in order across client instances", async () => { + const directory = mkdtempSync(join(tmpdir(), "agentcore-fixture-temporal-")); + directories.push(directory); + const command = new GetGatewayCommand({ gatewayIdentifier: "gateway-1" }); + record(directory, command, { + $sequence: [ + { gatewayId: "gateway-1", status: "READY" }, + { + $error: { + name: "ResourceNotFoundException", + message: "Gateway no longer exists.", + }, + }, + ], + }); + const firstClient = fixtureFactories(directory).createControlClient({ + region: "us-west-2", + }); + const secondClient = fixtureFactories(directory).createControlClient({ + region: "us-west-2", + }); + + await expect(firstClient.send(command)).resolves.toMatchObject({ + gatewayId: "gateway-1", + status: "READY", + }); + await expect(secondClient.send(command)).rejects.toMatchObject({ + name: "ResourceNotFoundException", + message: "Gateway no longer exists.", + }); + }); +}); diff --git a/src/testing/fixtures.tsx b/src/testing/fixtures.tsx index 5b8edadd3..a22800ae5 100644 --- a/src/testing/fixtures.tsx +++ b/src/testing/fixtures.tsx @@ -114,21 +114,58 @@ function normalizeResponse(response: unknown): unknown { // on a fresh account). A rejected send is recorded under this tag and re-thrown // with the same name/message on replay. const ERROR_TAG = "$error"; +const SEQUENCE_TAG = "$sequence"; interface TaggedError { [ERROR_TAG]: { name: string; message: string }; } +interface TaggedSequence { + [SEQUENCE_TAG]: unknown[]; +} + function isTaggedError(value: unknown): value is TaggedError { return typeof value === "object" && value !== null && ERROR_TAG in value; } +function isTaggedSequence(value: unknown): value is TaggedSequence { + return ( + typeof value === "object" && + value !== null && + SEQUENCE_TAG in value && + Array.isArray((value as TaggedSequence)[SEQUENCE_TAG]) + ); +} + function reviveError(tagged: TaggedError): Error { const error = new Error(tagged[ERROR_TAG].message); error.name = tagged[ERROR_TAG].name; return error; } +const recordingSequences = new Map(); +const replaySequencePositions = new Map(); + +function recordFixtureValue(path: string, value: unknown): void { + let sequence = recordingSequences.get(path); + if (!sequence) { + sequence = []; + recordingSequences.set(path, sequence); + } + sequence.push(value); + writeFileSync(path, stringify(sequence.length === 1 ? value : { [SEQUENCE_TAG]: sequence })); +} + +function nextFixtureValue(path: string, recorded: unknown): unknown { + if (!isTaggedSequence(recorded)) return recorded; + if (recorded[SEQUENCE_TAG].length === 0) { + throw new Error(`Fixture sequence ${path} is empty.`); + } + const position = replaySequencePositions.get(path) ?? 0; + replaySequencePositions.set(path, position + 1); + return recorded[SEQUENCE_TAG][Math.min(position, recorded[SEQUENCE_TAG].length - 1)]; +} + // makeRecordingSend returns a `.send()` that records to / replays from `dir`. // In record mode it delegates to the real client, saves the response (or the // service error), and propagates it; otherwise it reads the fixture, failing @@ -149,10 +186,10 @@ function makeRecordingSend Promise }>( const tagged: TaggedError = { [ERROR_TAG]: { name: (error as Error).name, message: (error as Error).message }, }; - writeFileSync(path, stringify(tagged)); + recordFixtureValue(path, tagged); throw error; } - writeFileSync(path, stringify(response)); + recordFixtureValue(path, response); return response; } @@ -162,7 +199,7 @@ function makeRecordingSend Promise }>( `Re-run with RECORD=1 to record it against the live API.`, ); } - const recorded = parse(readFileSync(path, "utf8")); + const recorded = nextFixtureValue(path, parse(readFileSync(path, "utf8"))); if (isTaggedError(recorded)) throw reviveError(recorded); return recorded; };