diff --git a/README.md b/README.md index ac53f4729..4e7c32867 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,14 @@ agentcore gateway rule list agentcore gateway rule get ``` +Gateway execution-role reconciliation supports one mutation at a time per +Gateway. Do not run Gateway, Target, or Connector create, update, or delete +commands concurrently for the same Gateway; the CLI does not provide +cross-process locking. If a Gateway deletion remains indeterminate after +retries, the CLI retains `AgentCoreCliGatewayExecutionPolicy` rather than risk +removing permissions from a live Gateway. After confirming the Gateway is +deleted, remove that inline policy manually from its former execution role. + --- # Architecture & patterns diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index e900fe74e..b12ee39f0 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -3,21 +3,33 @@ import { DeleteGatewayCommand, DeleteGatewayRuleCommand, DeleteGatewayTargetCommand, + GetApiKeyCredentialProviderCommand, GetGatewayCommand, GetGatewayTargetCommand, + GetOauth2CredentialProviderCommand, + GetTokenVaultCommand, ListGatewayTargetsCommand, TargetType, UpdateGatewayCommand, UpdateGatewayTargetCommand, type BedrockAgentCoreControlClient, + type CredentialProviderConfiguration, type GetGatewayResponse, type GetGatewayTargetResponse, type TargetSummary, } from "@aws-sdk/client-bedrock-agentcore-control"; +import { + DeleteRoleCommand, + DeleteRolePolicyCommand, + GetRoleCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; import { ERROR_SOURCE, ResultTruncationError } from "../errors"; import type { GatewayTargetUpdatePatch, GatewayUpdatePatch } from "../handlers/gateway/types"; import type { AwsClients } from "./types"; import { GatewayClient } from "./gateway"; +import { GatewayMutationIndeterminateError } from "./gatewayExecutionRole"; const options = { region: "us-west-2", endpointUrl: "https://agentcore.example.test" }; @@ -228,12 +240,205 @@ describe("GatewayClient Connector facade", () => { }); const OPTIONS = { region: "us-west-2" }; +const MANAGED_ROLE_NAME = "AgentCoreCliGateway-us-west-2-orders"; +const MANAGED_ROLE_ARN = `arn:aws:iam::123456789012:role/${MANAGED_ROLE_NAME}`; +const MANAGED_ROLE_TAGS = [ + { Key: "AgentCoreCLIManaged", Value: "true" }, + { Key: "AgentCoreCLIResourceType", Value: "Gateway" }, + { Key: "AgentCoreCLIRegion", Value: OPTIONS.region }, + { Key: "AgentCoreCLIResourceName", Value: "orders" }, +]; + +function managedRole() { + return { + Arn: MANAGED_ROLE_ARN, + RoleName: MANAGED_ROLE_NAME, + Tags: MANAGED_ROLE_TAGS, + }; +} + +test("preserves a newly created role when Gateway mutation outcome is indeterminate", async () => { + const iamCommands: unknown[] = []; + const clients = { + control: () => + ({ + send: async () => { + throw new Error("connection reset"); + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (command: unknown) => { + iamCommands.push(command); + if (command instanceof GetRoleCommand) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { Role: { Arn: MANAGED_ROLE_ARN, RoleName: MANAGED_ROLE_NAME } }; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + + await expect( + new GatewayClient(clients, { propagationDelayMs: 0 }).createGateway( + { name: "orders", authorizerType: "NONE" }, + OPTIONS, + ), + ).rejects.toBeInstanceOf(GatewayMutationIndeterminateError); + + expect(iamCommands.some((command) => command instanceof DeleteRolePolicyCommand)).toBe(false); + expect(iamCommands.some((command) => command instanceof DeleteRoleCommand)).toBe(false); +}); + +async function updateTargetCredentials( + currentCredentials: CredentialProviderConfiguration[], + desiredCredentials: CredentialProviderConfiguration[] | null, + secretSource: "MANAGED" | "EXTERNAL" = "MANAGED", +): Promise { + const apiKeyProviderArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; + const oauthProviderArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/orders"; + const secretArn = "arn:aws:secretsmanager:us-west-2:123456789012:secret:orders"; + const currentTarget = { + ...target(), + credentialProviderConfigurations: currentCredentials, + }; + const gatewayResponse = { + ...gateway(), + roleArn: MANAGED_ROLE_ARN, + workloadIdentityDetails: { + workloadIdentityArn: + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default/workload-identity/orders", + }, + }; + let targetReads = 0; + const policies: unknown[] = []; + const clients = { + control: () => + ({ + send: async (command: unknown) => { + if (command instanceof GetGatewayTargetCommand) { + targetReads += 1; + return targetReads === 1 ? currentTarget : { ...currentTarget, status: "READY" }; + } + if (command instanceof GetGatewayCommand) return gatewayResponse; + if (command instanceof ListGatewayTargetsCommand) { + return { items: [{ targetId: currentTarget.targetId }] }; + } + if (command instanceof GetApiKeyCredentialProviderCommand) { + return { + credentialProviderArn: apiKeyProviderArn, + apiKeySecretArn: { secretArn }, + apiKeySecretSource: secretSource, + }; + } + if (command instanceof GetOauth2CredentialProviderCommand) { + return { + credentialProviderArn: oauthProviderArn, + clientSecretArn: { secretArn }, + clientSecretSource: secretSource, + }; + } + if (command instanceof GetTokenVaultCommand) { + return { + tokenVaultId: "default", + kmsConfiguration: { + keyType: "CustomerManagedKey", + kmsKeyArn: "arn:aws:kms:us-west-2:123456789012:key/token-vault", + }, + }; + } + if (command instanceof UpdateGatewayTargetCommand) return { targetId: "target-1" }; + throw new Error(`unexpected command ${command}`); + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (command: GetRoleCommand | PutRolePolicyCommand) => { + if (command instanceof GetRoleCommand) return { Role: managedRole() }; + policies.push(JSON.parse(command.input.PolicyDocument!)); + return {}; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + + await new GatewayClient(clients, { propagationDelayMs: 0 }).updateGatewayTarget( + { + gatewayId: "gateway-1", + targetId: "target-1", + credentialProviderConfigurations: desiredCredentials, + }, + OPTIONS, + ); + return policies; +} + +test("adds credential grants when a Target changes from JWT passthrough to API key", async () => { + const providerArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; + const policies = await updateTargetCredentials( + [{ credentialProviderType: "JWT_PASSTHROUGH" }], + [ + { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { providerArn }, + }, + }, + ], + ); + + expect(policies).toHaveLength(2); + expect(JSON.stringify(policies.at(-1))).toContain("GetResourceApiKey"); + expect(JSON.stringify(policies.at(-1))).toContain("secretsmanager:GetSecretValue"); + expect(JSON.stringify(policies.at(-1))).toContain("kms:Decrypt"); +}); + +test.each(["API-key", "OAuth"] as const)( + "rejects external %s secrets before mutating a managed Target", + async (kind) => { + const configuration: CredentialProviderConfiguration = + kind === "API-key" + ? { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { + providerArn: + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders", + }, + }, + } + : { + credentialProviderType: "OAUTH", + credentialProvider: { + oauthCredentialProvider: { + providerArn: + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/orders", + scopes: ["orders.read"], + grantType: "CLIENT_CREDENTIALS", + }, + }, + }; + + await expect( + updateTargetCredentials( + [{ credentialProviderType: "JWT_PASSTHROUGH" }], + [configuration], + "EXTERNAL", + ), + ).rejects.toThrow(/--skip-role-policy-update/); + }, +); function gateway(): GetGatewayResponse { return { gatewayId: "gateway-1", + gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", name: "orders", roleArn: "arn:aws:iam::123456789012:role/orders", + status: "READY", authorizerType: "CUSTOM_JWT", authorizerConfiguration: { customJWTAuthorizer: { @@ -273,29 +478,112 @@ function target(): GetGatewayTargetResponse { } test("maps Gateway, Target, and Rule selectors to their delete commands", async () => { - const { client, commands } = recordingGatewayClient([{}, {}, {}]); + const { client, commands } = recordingGatewayClient([gateway(), {}, gateway(), {}, {}]); await client.deleteGateway("gateway-1", OPTIONS); - await client.deleteGatewayTarget("gateway-1", "target-1", OPTIONS); + await client.deleteGatewayTarget({ gatewayId: "gateway-1", targetId: "target-1" }, OPTIONS); await client.deleteGatewayRule("gateway-1", "rule-1", OPTIONS); - expect(commands).toHaveLength(3); - expect(commands[0]).toBeInstanceOf(DeleteGatewayCommand); - expect((commands[0] as DeleteGatewayCommand).input).toEqual({ + expect(commands).toHaveLength(5); + expect(commands[0]).toBeInstanceOf(GetGatewayCommand); + expect(commands[1]).toBeInstanceOf(DeleteGatewayCommand); + expect((commands[1] as DeleteGatewayCommand).input).toEqual({ gatewayIdentifier: "gateway-1", }); - expect(commands[1]).toBeInstanceOf(DeleteGatewayTargetCommand); - expect((commands[1] as DeleteGatewayTargetCommand).input).toEqual({ + expect(commands[2]).toBeInstanceOf(GetGatewayCommand); + expect(commands[3]).toBeInstanceOf(DeleteGatewayTargetCommand); + expect((commands[3] as DeleteGatewayTargetCommand).input).toEqual({ gatewayIdentifier: "gateway-1", targetId: "target-1", }); - expect(commands[2]).toBeInstanceOf(DeleteGatewayRuleCommand); - expect((commands[2] as DeleteGatewayRuleCommand).input).toEqual({ + expect(commands[4]).toBeInstanceOf(DeleteGatewayRuleCommand); + expect((commands[4] as DeleteGatewayRuleCommand).input).toEqual({ gatewayIdentifier: "gateway-1", ruleId: "rule-1", }); }); +test("reconciles stale grants when a Target delete retry finds the Target missing", async () => { + const policies: unknown[] = []; + const clients = { + control: () => + ({ + send: async (command: unknown) => { + if (command instanceof GetGatewayCommand) { + return { ...gateway(), roleArn: MANAGED_ROLE_ARN }; + } + if (command instanceof DeleteGatewayTargetCommand) { + const error = new Error("missing"); + error.name = "ResourceNotFoundException"; + throw error; + } + if (command instanceof ListGatewayTargetsCommand) return { items: [] }; + throw new Error(`unexpected command ${command}`); + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (command: GetRoleCommand | PutRolePolicyCommand) => { + if (command instanceof GetRoleCommand) return { Role: managedRole() }; + policies.push(JSON.parse(command.input.PolicyDocument!)); + return {}; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + + await expect( + new GatewayClient(clients).deleteGatewayTarget( + { gatewayId: "gateway-1", targetId: "target-1" }, + OPTIONS, + ), + ).rejects.toMatchObject({ name: "ResourceNotFoundException" }); + + expect(policies).toHaveLength(1); + expect(JSON.stringify(policies[0])).toContain("bedrock-agentcore:InvokeGateway"); +}); + +test("retries an indeterminate Gateway deletion read before preserving its policy", async () => { + let gatewayReads = 0; + const iamCommands: unknown[] = []; + const clients = { + control: () => + ({ + send: async (command: unknown) => { + if (command instanceof GetGatewayCommand) { + gatewayReads += 1; + if (gatewayReads === 1) { + return { ...gateway(), roleArn: MANAGED_ROLE_ARN }; + } + const error = new Error(gatewayReads === 2 ? "throttled" : "missing"); + error.name = gatewayReads === 2 ? "ThrottlingException" : "ResourceNotFoundException"; + throw error; + } + if (command instanceof DeleteGatewayCommand) { + return { gatewayId: "gateway-1", status: "DELETING" }; + } + throw new Error(`unexpected command ${command}`); + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (command: unknown) => { + iamCommands.push(command); + if (command instanceof GetRoleCommand) return { Role: managedRole() }; + return {}; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + + await expect( + new GatewayClient(clients, { waitAttempts: 2, waitDelayMs: 0 }).deleteGateway( + "gateway-1", + OPTIONS, + ), + ).resolves.toMatchObject({ gatewayId: "gateway-1" }); + + expect(iamCommands.some((command) => command instanceof DeleteRolePolicyCommand)).toBe(true); +}); + function recordingGatewayClient(responses: unknown[]): { client: GatewayClient; commands: unknown[]; @@ -339,17 +627,88 @@ async function targetUpdateInput( patch: GatewayTargetUpdatePatch, current: GetGatewayTargetResponse = target(), ): Promise { - const { client, commands } = recordingGatewayClient([current, {}]); + const { client, commands } = recordingGatewayClient([current, gateway(), {}]); await client.updateGatewayTarget(patch, OPTIONS); expect(commands[0]).toBeInstanceOf(GetGatewayTargetCommand); expect((commands[0] as GetGatewayTargetCommand).input).toEqual({ gatewayIdentifier: patch.gatewayId, targetId: patch.targetId, }); - return (commands[1] as UpdateGatewayTargetCommand).input; + return (commands[2] as UpdateGatewayTargetCommand).input; } describe("GatewayClient updateGateway", () => { + test("does not touch IAM when replacing a role with policy updates skipped", async () => { + const { client, commands } = recordingGatewayClient([ + { ...gateway(), roleArn: MANAGED_ROLE_ARN }, + { gatewayId: "gateway-1" }, + ]); + const replacementRoleArn = "arn:aws:iam::123456789012:role/customer-managed"; + + await client.updateGateway( + { + id: "gateway-1", + roleArn: replacementRoleArn, + skipRolePolicyUpdate: true, + }, + OPTIONS, + ); + + expect(commands).toHaveLength(2); + expect(commands[1]).toBeInstanceOf(UpdateGatewayCommand); + expect((commands[1] as UpdateGatewayCommand).input.roleArn).toBe(replacementRoleArn); + }); + + test("stages Policy Engine permissions before updating a CLI-owned Gateway", async () => { + const order: string[] = []; + const current = { + ...gateway(), + gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gateway-1", + roleArn: MANAGED_ROLE_ARN, + policyEngineConfiguration: undefined, + }; + const clients = { + control: () => + ({ + send: async (command: GetGatewayCommand | UpdateGatewayCommand) => { + if (command instanceof GetGatewayCommand) { + order.push("get"); + return current; + } + if (command instanceof ListGatewayTargetsCommand) return { items: [] }; + order.push("update"); + return { gatewayId: "gateway-1" }; + }, + }) as unknown as BedrockAgentCoreControlClient, + iam: () => + ({ + send: async (command: GetRoleCommand | PutRolePolicyCommand) => { + if (command instanceof GetRoleCommand) { + order.push("role"); + return { Role: managedRole() }; + } + order.push("policy"); + return {}; + }, + }) as unknown as IAMClient, + } as unknown as AwsClients; + const client = new GatewayClient(clients, { propagationDelayMs: 0 }); + + await client.updateGateway( + { + id: "gateway-1", + roleArn: MANAGED_ROLE_ARN, + policyEngineConfiguration: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/engine-2", + mode: "ENFORCE", + }, + }, + OPTIONS, + ); + + expect(order).toEqual(["get", "role", "policy", "update", "get"]); + }); + test("clears requested fields and merges a Policy Engine mode change", async () => { expect( await gatewayUpdateInput({ @@ -485,6 +844,7 @@ describe("GatewayClient updateGatewayConnector", () => { }; const { client, commands } = recordingGatewayClient([ { targetId: "target-1", targetConfiguration } as GetGatewayTargetResponse, + gateway(), {}, ]); @@ -497,8 +857,8 @@ describe("GatewayClient updateGatewayConnector", () => { OPTIONS, ); - expect(commands[1]).toBeInstanceOf(UpdateGatewayTargetCommand); - expect((commands[1] as UpdateGatewayTargetCommand).input.targetConfiguration).toEqual( + expect(commands[2]).toBeInstanceOf(UpdateGatewayTargetCommand); + expect((commands[2] as UpdateGatewayTargetCommand).input.targetConfiguration).toEqual( targetConfiguration, ); }); diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 5a3812bef..6527abd76 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -5,9 +5,12 @@ import { DeleteGatewayCommand, DeleteGatewayRuleCommand, DeleteGatewayTargetCommand, + GetApiKeyCredentialProviderCommand, GetGatewayCommand, GetGatewayRuleCommand, GetGatewayTargetCommand, + GetOauth2CredentialProviderCommand, + GetTokenVaultCommand, ListGatewayRulesCommand, ListGatewaysCommand, ListGatewayTargetsCommand, @@ -46,18 +49,69 @@ import type { CreateGatewayInput, CreateGatewayRuleInput, CreateGatewayTargetInput, + GatewayTargetDeleteInput, GatewayRuleUpdateInput, GatewayTargetUpdatePatch, GatewayUpdatePatch, } from "../handlers/gateway/types"; import type { AwsClients, CoreOptions } from "./types"; +import { + GatewayExecutionRole, + GatewayMutationIndeterminateError, + GatewayMutationTerminalError, + matchesGatewayExecutionRole, + type GatewayExecutionRoleOptions, +} from "./gatewayExecutionRole"; +import { gatewayPolicy, type GatewayPolicyStatement } from "./gatewayPolicy"; import { toClientConfig } from "./utils"; const DEFAULT_CONNECTOR_PAGE_SIZE = 100; const MAX_CONNECTOR_TARGET_PAGES = 101; +const DEFAULT_WAIT_ATTEMPTS = 150; +const DEFAULT_WAIT_DELAY_MS = 2_000; + +type GatewayClientOptions = GatewayExecutionRoleOptions & { + waitAttempts?: number; + waitDelayMs?: number; +}; + +type GatewayCredentialState = { + secrets: ReadonlyMap; + tokenVaultKmsKeyArn?: string; +}; + +type GatewayPolicyTargetState = Pick< + GetGatewayTargetResponse, + "targetConfiguration" | "credentialProviderConfigurations" +> & { + targetId?: GetGatewayTargetResponse["targetId"]; +}; + +type GatewayFamilyState = { + gateway: GetGatewayResponse; + targets: readonly GatewayPolicyTargetState[]; +}; + +type GatewayPolicySnapshots = { + current: GatewayPolicyStatement[]; + desired: GatewayPolicyStatement[]; +}; export class GatewayClient implements CoreGatewayClient { - constructor(private readonly clients: AwsClients) {} + constructor( + private readonly clients: AwsClients, + private readonly roleOptions: GatewayClientOptions = {}, + ) {} + + async getGatewayRolePolicyWarning( + gatewayId: string, + options: CoreOptions, + ): Promise { + const gateway = await this.getGateway(gatewayId, options); + const name = GatewayClient.required(gateway.name, "Gateway", "name"); + const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); + return (await this.managedExecutionRole(name, roleArn, options)) ? undefined : roleArn; + } async createGateway( input: CreateGatewayInput, @@ -65,13 +119,70 @@ export class GatewayClient implements CoreGatewayClient { ): Promise { const control = this.clients.control(toClientConfig(options)); const { protocol, roleArn, ...request } = input; - return control.send( - new CreateGatewayCommand({ - ...request, - roleArn, - ...(protocol === "mcp" ? { protocolType: "MCP" as const } : {}), + const operation = (executionRoleArn: string) => + control.send( + new CreateGatewayCommand({ + ...request, + roleArn: executionRoleArn, + ...(protocol === "mcp" ? { protocolType: "MCP" as const } : {}), + }), + ); + if (roleArn) return operation(roleArn); + const roleManager = this.executionRole(options); + const role = await roleManager.ensure(request.name!, options.region); + let response: CreateGatewayResponse; + let createdResponse: CreateGatewayResponse | undefined; + let mutationAccepted = false; + try { + const staged = gatewayPolicy({ + policyEngineArn: request.policyEngineConfiguration?.arn, + interceptorConfigurations: request.interceptorConfigurations, + }); + const current = role.created ? [] : await roleManager.read(role.arn); + response = await roleManager.update( + role.arn, + current, + staged, + { + mutate: async () => { + const created = await this.mutate( + () => operation(role.arn), + `Gateway "${request.name}"`, + ); + mutationAccepted = true; + createdResponse = created; + return created; + }, + stabilize: async () => { + const gatewayId = GatewayClient.required( + createdResponse?.gatewayId, + "Created Gateway", + "ID", + ); + await this.waitForGateway(gatewayId, options); + }, + }, + { forcePropagation: role.created }, + ); + } catch (error) { + if ( + error instanceof GatewayMutationTerminalError || + (!mutationAccepted && !(error instanceof GatewayMutationIndeterminateError)) + ) { + await roleManager.rollbackCreate(role); + } + throw error; + } + const gatewayArn = GatewayClient.required(response.gatewayArn, "Created Gateway", "ARN"); + await roleManager.replace( + role.arn, + gatewayPolicy({ + gatewayArn, + policyEngineArn: request.policyEngineConfiguration?.arn, + interceptorConfigurations: request.interceptorConfigurations, }), ); + return response; } async getGateway(id: string, options: CoreOptions): Promise { @@ -148,7 +259,45 @@ export class GatewayClient implements CoreGatewayClient { exceptionLevel, wafConfiguration, }; - return control.send(new UpdateGatewayCommand(request)); + const operation = () => control.send(new UpdateGatewayCommand(request)); + if (patch.skipRolePolicyUpdate) return operation(); + if (patch.roleArn && patch.roleArn !== roleArn) { + const response = await operation(); + const roleManager = await this.managedExecutionRole(name, roleArn, options); + if (roleManager) { + await this.waitForGateway(patch.id, options); + await roleManager.replace(roleArn, []); + } + return response; + } + if ( + patch.policyEngineConfiguration === undefined && + patch.interceptorConfigurations === undefined && + patch.customTransformConfiguration === undefined + ) { + return operation(); + } + + const roleManager = await this.managedExecutionRole(name, roleArn, options); + if (!roleManager) return operation(); + const targets = await this.targetInventory(patch.id, options); + const snapshots = await this.policySnapshots( + { gateway: current, targets }, + { + gateway: { + ...current, + policyEngineConfiguration, + interceptorConfigurations, + customTransformConfiguration, + }, + targets, + }, + options, + ); + return roleManager.update(roleArn, snapshots.current, snapshots.desired, { + mutate: () => this.mutate(operation, resource), + stabilize: () => this.waitForGateway(patch.id, options), + }); } async listGateways( @@ -162,9 +311,18 @@ export class GatewayClient implements CoreGatewayClient { } async deleteGateway(id: string, options: CoreOptions): Promise { - return this.clients - .control(toClientConfig(options)) - .send(new DeleteGatewayCommand({ gatewayIdentifier: id })); + const control = this.clients.control(toClientConfig(options)); + const operation = () => control.send(new DeleteGatewayCommand({ gatewayIdentifier: id })); + const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: id })); + const name = GatewayClient.required(gateway.name, "Gateway", "name"); + const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); + const roleManager = await this.managedExecutionRole(name, roleArn, options); + if (!roleManager) return operation(); + + const response = await operation(); + await this.waitForGatewayDeletion(id, options); + await roleManager.replace(roleArn, []); + return response; } async getGatewayTarget( @@ -199,9 +357,53 @@ export class GatewayClient implements CoreGatewayClient { input: CreateGatewayTargetInput, options: CoreOptions, ): Promise { - return this.clients - .control(toClientConfig(options)) - .send(new CreateGatewayTargetCommand(input)); + const control = this.clients.control(toClientConfig(options)); + const { skipRolePolicyUpdate, ...request } = input; + const operation = () => control.send(new CreateGatewayTargetCommand(request)); + if (skipRolePolicyUpdate) return operation(); + const gateway = await control.send( + new GetGatewayCommand({ gatewayIdentifier: request.gatewayIdentifier }), + ); + const name = GatewayClient.required(gateway.name, "Gateway", "name"); + const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); + const roleManager = await this.managedExecutionRole(name, roleArn, options); + if (!roleManager) return operation(); + + const targets = await this.targetInventory(request.gatewayIdentifier!, options); + const targetConfiguration = GatewayClient.required( + request.targetConfiguration, + "Gateway Target", + "configuration", + ); + const desiredTargets = [ + ...targets, + { + targetConfiguration, + credentialProviderConfigurations: request.credentialProviderConfigurations, + }, + ]; + const snapshots = await this.policySnapshots( + { gateway, targets }, + { gateway, targets: desiredTargets }, + options, + ); + let targetId: string | undefined; + return roleManager.update(roleArn, snapshots.current, snapshots.desired, { + mutate: async () => { + const response = await this.mutate( + operation, + `Gateway Target "${request.name ?? "unnamed"}"`, + ); + if (!response.targetId) { + throw new GatewayMutationIndeterminateError( + `Gateway Target "${request.name ?? "unnamed"}"`, + ); + } + targetId = response.targetId; + return response; + }, + stabilize: () => this.waitForGatewayTarget(request.gatewayIdentifier!, targetId!, options), + }); } async getGatewayConnector( @@ -268,16 +470,36 @@ export class GatewayClient implements CoreGatewayClient { } async deleteGatewayTarget( - gatewayId: string, - targetId: string, + input: GatewayTargetDeleteInput, options: CoreOptions, ): Promise { - return this.clients.control(toClientConfig(options)).send( - new DeleteGatewayTargetCommand({ - gatewayIdentifier: gatewayId, - targetId, - }), - ); + const control = this.clients.control(toClientConfig(options)); + const { gatewayId, targetId, skipRolePolicyUpdate } = input; + const request = { gatewayIdentifier: gatewayId, targetId }; + const operation = () => control.send(new DeleteGatewayTargetCommand(request)); + if (skipRolePolicyUpdate) return operation(); + const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: gatewayId })); + const name = GatewayClient.required(gateway.name, "Gateway", "name"); + const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); + const roleManager = await this.managedExecutionRole(name, roleArn, options); + if (!roleManager) return operation(); + + const remaining = await this.targetInventory(gatewayId, options, undefined, targetId); + const credentials = await this.credentials(remaining, options); + const desiredPolicy = this.policy(gateway, remaining, credentials); + const reconcile = () => roleManager.replace(roleArn, desiredPolicy); + + let response: DeleteGatewayTargetResponse; + try { + response = await operation(); + } catch (error) { + if ((error as Error).name !== "ResourceNotFoundException") throw error; + await reconcile(); + throw error; + } + await this.waitForGatewayTargetDeletion(gatewayId, targetId, options); + await reconcile(); + return response; } async getGatewayRule( @@ -379,9 +601,8 @@ export class GatewayClient implements CoreGatewayClient { current.credentialProviderConfigurations, patch.credentialProviderConfigurations, ); - const metadataConfiguration = GatewayClient.replace( - current.metadataConfiguration, - patch.metadataConfiguration, + const metadataConfiguration = GatewayClient.nonEmptyMetadata( + GatewayClient.replace(current.metadataConfiguration, patch.metadataConfiguration), ); const privateEndpoint = GatewayClient.replace(current.privateEndpoint, patch.privateEndpoint); const request: UpdateGatewayTargetRequest = { @@ -399,7 +620,35 @@ export class GatewayClient implements CoreGatewayClient { "Connector updates require an MCP or inference connector Target configuration", ); } - return control.send(new UpdateGatewayTargetCommand(request)); + const operation = () => control.send(new UpdateGatewayTargetCommand(request)); + if (patch.skipRolePolicyUpdate) return operation(); + const gateway = await control.send( + new GetGatewayCommand({ gatewayIdentifier: patch.gatewayId }), + ); + const gatewayName = GatewayClient.required(gateway.name, "Gateway", "name"); + const roleArn = GatewayClient.required(gateway.roleArn, "Gateway", "role ARN"); + const roleManager = await this.managedExecutionRole(gatewayName, roleArn, options); + if (!roleManager) return operation(); + + const targets = await this.targetInventory(patch.gatewayId, options, current); + const desiredTargets = targets.map((target) => + target.targetId === patch.targetId + ? { ...target, targetConfiguration, credentialProviderConfigurations } + : target, + ); + const snapshots = await this.policySnapshots( + { gateway, targets }, + { gateway, targets: desiredTargets }, + options, + ); + return roleManager.update(roleArn, snapshots.current, snapshots.desired, { + mutate: () => + this.mutate( + operation, + `Gateway Target "${patch.targetId}" under Gateway "${patch.gatewayId}"`, + ), + stabilize: () => this.waitForGatewayTarget(patch.gatewayId, patch.targetId, options), + }); } private static replace( @@ -410,6 +659,266 @@ export class GatewayClient implements CoreGatewayClient { return replacement === null ? undefined : replacement; } + private static nonEmptyMetadata( + configuration: UpdateGatewayTargetRequest["metadataConfiguration"], + ): UpdateGatewayTargetRequest["metadataConfiguration"] { + if ( + configuration && + Object.values(configuration).some((values) => values && values.length > 0) + ) { + return configuration; + } + return undefined; + } + + private executionRole(options: CoreOptions): GatewayExecutionRole { + return new GatewayExecutionRole(this.clients.iam({ region: options.region }), this.roleOptions); + } + + private async managedExecutionRole( + gatewayName: string, + roleArn: string, + options: CoreOptions, + ): Promise { + if (!matchesGatewayExecutionRole(gatewayName, options.region, roleArn)) return undefined; + const role = this.executionRole(options); + return (await role.isManaged(gatewayName, options.region, roleArn)) ? role : undefined; + } + + private policy( + gateway: GetGatewayResponse, + targets: readonly GatewayPolicyTargetState[], + credentials: GatewayCredentialState = { secrets: new Map() }, + ): GatewayPolicyStatement[] { + return gatewayPolicy({ + gatewayArn: GatewayClient.required(gateway.gatewayArn, "Gateway", "ARN"), + workloadIdentityArn: gateway.workloadIdentityDetails?.workloadIdentityArn, + policyEngineArn: gateway.policyEngineConfiguration?.arn, + interceptorConfigurations: gateway.interceptorConfigurations, + customTransformConfiguration: gateway.customTransformConfiguration, + credentialSecrets: credentials.secrets, + tokenVaultKmsKeyArn: credentials.tokenVaultKmsKeyArn, + targets: targets.map((target) => ({ + targetConfiguration: GatewayClient.required( + target.targetConfiguration, + "Gateway Target", + "configuration", + ), + credentialProviderConfigurations: target.credentialProviderConfigurations, + })), + }); + } + + private async policySnapshots( + current: GatewayFamilyState, + desired: GatewayFamilyState, + options: CoreOptions, + ): Promise { + const credentials = await this.credentials([...current.targets, ...desired.targets], options); + return { + current: this.policy(current.gateway, current.targets, credentials), + desired: this.policy(desired.gateway, desired.targets, credentials), + }; + } + + private async credentials( + targets: readonly Pick[], + options: CoreOptions, + ): Promise { + const providers = new Map(); + for (const target of targets) { + for (const configuration of target.credentialProviderConfigurations ?? []) { + const kind = + configuration.credentialProviderType === "API_KEY" + ? "api-key" + : configuration.credentialProviderType === "OAUTH" + ? "oauth" + : undefined; + if (!kind) continue; + const providerArn = + kind === "api-key" + ? configuration.credentialProvider?.apiKeyCredentialProvider?.providerArn + : configuration.credentialProvider?.oauthCredentialProvider?.providerArn; + if (!providerArn) throw new Error(`${configuration.credentialProviderType} ARN is missing`); + if (providers.get(providerArn) && providers.get(providerArn) !== kind) { + throw new Error(`Credential provider ${providerArn} is used as two provider types`); + } + providers.set(providerArn, kind); + } + } + + const control = this.clients.control(toClientConfig(options)); + const secrets = new Map(); + for (const [providerArn, kind] of providers) { + const name = credentialProviderName(providerArn, kind); + const response = + kind === "api-key" + ? await control.send(new GetApiKeyCredentialProviderCommand({ name })) + : await control.send(new GetOauth2CredentialProviderCommand({ name })); + if (response.credentialProviderArn !== providerArn) { + throw new Error(`Credential provider ${name} returned an unexpected ARN`); + } + if ( + ("apiKeySecretSource" in response && response.apiKeySecretSource === "EXTERNAL") || + ("clientSecretSource" in response && response.clientSecretSource === "EXTERNAL") + ) { + throw new InputValidationError( + `${kind === "api-key" ? "API key" : "OAuth"} credential provider ${providerArn} uses an external secret; rerun with --skip-role-policy-update and manage its secret and KMS permissions externally`, + ); + } + const secretArn = + "apiKeySecretArn" in response + ? response.apiKeySecretArn?.secretArn + : response.clientSecretArn?.secretArn; + if (!secretArn) throw new Error(`Credential provider ${providerArn} returned no secret ARN`); + secrets.set(providerArn, secretArn); + } + if (providers.size === 0) return { secrets }; + const vault = await control.send(new GetTokenVaultCommand({ tokenVaultId: "default" })); + const tokenVaultKmsKeyArn = + vault.kmsConfiguration?.keyType === "CustomerManagedKey" + ? vault.kmsConfiguration.kmsKeyArn + : undefined; + if (vault.kmsConfiguration?.keyType === "CustomerManagedKey" && !tokenVaultKmsKeyArn) { + throw new Error("Default Token Vault returned no customer-managed KMS key ARN"); + } + return { secrets, tokenVaultKmsKeyArn }; + } + + private async targetInventory( + gatewayId: string, + options: CoreOptions, + known?: GetGatewayTargetResponse, + excludedTargetId?: string, + ): Promise { + const targets: GetGatewayTargetResponse[] = []; + let nextToken: string | undefined; + for (let page = 0; page < MAX_CONNECTOR_TARGET_PAGES; page++) { + const response = await this.listGatewayTargets( + gatewayId, + nextToken, + DEFAULT_CONNECTOR_PAGE_SIZE, + options, + ); + for (const summary of response.items ?? []) { + const targetId = GatewayClient.required(summary.targetId, "Gateway Target", "ID"); + if (targetId === excludedTargetId) continue; + targets.push( + known?.targetId === targetId + ? known + : await this.getGatewayTarget(gatewayId, targetId, options), + ); + } + if (!response.nextToken) return targets; + nextToken = response.nextToken; + } + throw new ResultTruncationError( + `Gateway Target discovery exceeded ${MAX_CONNECTOR_TARGET_PAGES} pages; policy inventory is incomplete`, + ); + } + + private async mutate(operation: () => Promise, resource: string): Promise { + try { + return await operation(); + } catch (error) { + const statusCode = (error as { $metadata?: { httpStatusCode?: number } }).$metadata + ?.httpStatusCode; + if (statusCode === undefined || statusCode >= 500) { + throw new GatewayMutationIndeterminateError(resource, { cause: error }); + } + throw error; + } + } + + private async waitForGateway(gatewayId: string, options: CoreOptions): Promise { + await this.waitForTerminal( + `Gateway "${gatewayId}"`, + () => this.getGateway(gatewayId, options), + ["READY"], + ["FAILED", "UPDATE_UNSUCCESSFUL"], + ); + } + + private async waitForGatewayTarget( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise { + await this.waitForTerminal( + `Gateway Target "${targetId}"`, + () => this.getGatewayTarget(gatewayId, targetId, options), + ["READY", "CREATE_PENDING_AUTH", "UPDATE_PENDING_AUTH", "SYNCHRONIZE_PENDING_AUTH"], + ["FAILED", "UPDATE_UNSUCCESSFUL", "SYNCHRONIZE_UNSUCCESSFUL"], + ); + } + + private async waitForGatewayTargetDeletion( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise { + await this.waitForTerminal( + `Gateway Target "${targetId}"`, + () => this.getGatewayTarget(gatewayId, targetId, options), + [], + ["FAILED", "UPDATE_UNSUCCESSFUL", "SYNCHRONIZE_UNSUCCESSFUL"], + true, + ); + } + + private async waitForGatewayDeletion(gatewayId: string, options: CoreOptions): Promise { + await this.waitForTerminal( + `Gateway "${gatewayId}"`, + () => this.getGateway(gatewayId, options), + [], + ["FAILED", "UPDATE_UNSUCCESSFUL"], + true, + ); + } + + private async waitForTerminal( + resource: string, + read: () => Promise<{ status?: string; statusReasons?: string[] }>, + successful: readonly string[], + failed: readonly string[], + missingIsSuccess = false, + ): Promise { + const attempts = this.roleOptions.waitAttempts ?? DEFAULT_WAIT_ATTEMPTS; + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const current = await read(); + lastError = undefined; + if (current.status && successful.includes(current.status)) return; + if (current.status && failed.includes(current.status)) { + throw new GatewayMutationTerminalError( + resource, + current.status, + current.statusReasons ?? [], + ); + } + } catch (error) { + if (error instanceof GatewayMutationTerminalError) throw error; + if ((error as Error).name === "ResourceNotFoundException" && missingIsSuccess) return; + lastError = error; + } + if (attempt < attempts - 1) await this.wait(); + } + throw new GatewayMutationIndeterminateError( + resource, + lastError === undefined ? undefined : { cause: lastError }, + ); + } + + private async wait(): Promise { + const milliseconds = this.roleOptions.waitDelayMs ?? DEFAULT_WAIT_DELAY_MS; + if (milliseconds > 0) { + await ( + this.roleOptions.sleep ?? ((delay) => new Promise((resolve) => setTimeout(resolve, delay))) + )(milliseconds); + } + } + private static required(value: T | undefined, resource: string, field: string): T { if (value === undefined) { throw new AgentCoreCLIError(`${resource} is missing its ${field} required for update`, { @@ -426,3 +935,11 @@ export class GatewayClient implements CoreGatewayClient { ); } } + +function credentialProviderName(providerArn: string, kind: "api-key" | "oauth"): string { + const resource = providerArn.split(":").slice(5).join(":"); + const type = kind === "api-key" ? "apikeycredentialprovider" : "oauth2credentialprovider"; + const name = resource.match(new RegExp(`^token-vault/[^/]+/${type}/([^/]+)$`))?.[1]; + if (!name) throw new Error(`Invalid ${kind} credential provider ARN: ${providerArn}`); + return name; +} diff --git a/src/core/gatewayExecutionRole.test.ts b/src/core/gatewayExecutionRole.test.ts new file mode 100644 index 000000000..b3ccf6477 --- /dev/null +++ b/src/core/gatewayExecutionRole.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from "bun:test"; +import { + CreateRoleCommand, + GetRoleCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import { + GatewayExecutionRole, + GatewayMutationIndeterminateError, + GatewayMutationTerminalError, + gatewayRoleName, + matchesGatewayExecutionRole, +} from "./gatewayExecutionRole"; +import type { GatewayPolicyStatement } from "./gatewayPolicy"; + +const REGION = "us-west-2"; +const ROLE_NAME = "AgentCoreCliGateway-us-west-2-orders"; +const ROLE_ARN = `arn:aws:iam::123456789012:role/${ROLE_NAME}`; +const ROLE_TAGS = [ + { Key: "AgentCoreCLIManaged", Value: "true" }, + { Key: "AgentCoreCLIResourceType", Value: "Gateway" }, + { Key: "AgentCoreCLIRegion", Value: REGION }, + { Key: "AgentCoreCLIResourceName", Value: "orders" }, +]; +const current: GatewayPolicyStatement[] = [ + { Effect: "Allow", Action: ["lambda:InvokeFunction"], Resource: ["arn:lambda:old"] }, +]; +const desired: GatewayPolicyStatement[] = [ + { Effect: "Allow", Action: ["lambda:InvokeFunction"], Resource: ["arn:lambda:new"] }, +]; + +function policyWrites(): { iam: IAMClient; writes: GatewayPolicyStatement[][] } { + const writes: GatewayPolicyStatement[][] = []; + const iam = { + send: async (command: PutRolePolicyCommand) => { + expect(command).toBeInstanceOf(PutRolePolicyCommand); + writes.push(JSON.parse(command.input.PolicyDocument!).Statement); + return {}; + }, + } as unknown as IAMClient; + return { iam, writes }; +} + +describe("GatewayExecutionRole ownership", () => { + test("creates a region-scoped role with CLI ownership tags", async () => { + const commands: unknown[] = []; + const iam = { + send: async (command: unknown) => { + commands.push(command); + if (command instanceof GetRoleCommand) { + const error = new Error("missing"); + error.name = "NoSuchEntityException"; + throw error; + } + return { Role: { Arn: ROLE_ARN } }; + }, + } as unknown as IAMClient; + + await expect(new GatewayExecutionRole(iam).ensure("orders", REGION)).resolves.toEqual({ + arn: ROLE_ARN, + name: ROLE_NAME, + created: true, + }); + + expect(gatewayRoleName("orders", REGION)).toBe(ROLE_NAME); + expect((commands[1] as CreateRoleCommand).input).toMatchObject({ + RoleName: ROLE_NAME, + Tags: ROLE_TAGS, + }); + }); + + test("requires both the regional name and ownership tags", async () => { + let calls = 0; + const iam = { + send: async () => { + calls += 1; + return { + Role: { + Arn: ROLE_ARN, + RoleName: ROLE_NAME, + Tags: ROLE_TAGS, + }, + }; + }, + } as unknown as IAMClient; + const role = new GatewayExecutionRole(iam); + + await expect(role.isManaged("orders", REGION, ROLE_ARN)).resolves.toBe(true); + await expect(role.isManaged("orders", "us-east-1", ROLE_ARN)).resolves.toBe(false); + await expect( + new GatewayExecutionRole({ + send: async () => ({ Role: { Arn: ROLE_ARN, RoleName: ROLE_NAME } }), + } as unknown as IAMClient).isManaged("orders", REGION, ROLE_ARN), + ).resolves.toBe(false); + expect(matchesGatewayExecutionRole("orders", REGION, ROLE_ARN)).toBe(true); + expect(calls).toBe(1); + }); + + test("rejects an untagged role that collides with the generated name", async () => { + const iam = { + send: async () => ({ + Role: { Arn: ROLE_ARN, RoleName: ROLE_NAME }, + }), + } as unknown as IAMClient; + + await expect(new GatewayExecutionRole(iam).ensure("orders", REGION)).rejects.toThrow( + /not tagged as managed by the AgentCore CLI/, + ); + }); +}); + +describe("GatewayExecutionRole update", () => { + test("stages current and desired grants before writing exact desired", async () => { + const { iam, writes } = policyWrites(); + const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); + + await role.update(ROLE_ARN, current, desired, { + mutate: async () => { + expect(writes).toEqual([[...current, ...desired]]); + return "updated"; + }, + stabilize: async () => {}, + }); + + expect(writes).toEqual([[...current, ...desired], desired]); + }); + + test("rewrites exact desired grants after a successful retry with no state delta", async () => { + const { iam, writes } = policyWrites(); + const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); + + await role.update(ROLE_ARN, desired, desired, { + mutate: async () => "updated", + stabilize: async () => {}, + }); + + expect(writes).toEqual([desired]); + }); + + test("restores current grants when the mutation is rejected", async () => { + const { iam, writes } = policyWrites(); + const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); + + await expect( + role.update(ROLE_ARN, desired, desired, { + mutate: async () => { + throw new Error("update failed"); + }, + stabilize: async () => {}, + }), + ).rejects.toThrow("update failed"); + + expect(writes).toEqual([desired]); + }); + + test("restores current grants after a terminal service failure", async () => { + const { iam, writes } = policyWrites(); + const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); + + await expect( + role.update(ROLE_ARN, desired, desired, { + mutate: async () => "accepted", + stabilize: async () => { + throw new GatewayMutationTerminalError("Gateway", "FAILED", ["invalid"]); + }, + }), + ).rejects.toThrow("Gateway reached FAILED: invalid"); + + expect(writes).toEqual([desired]); + }); + + test.each(["mutation", "stabilization"] as const)( + "retains transition grants after indeterminate %s", + async (phase) => { + const { iam, writes } = policyWrites(); + const role = new GatewayExecutionRole(iam, { propagationDelayMs: 0 }); + + await expect( + role.update(ROLE_ARN, current, desired, { + mutate: async () => { + if (phase === "mutation") { + throw new GatewayMutationIndeterminateError("Gateway"); + } + return "accepted"; + }, + stabilize: async () => { + if (phase === "stabilization") { + throw new GatewayMutationIndeterminateError("Gateway"); + } + }, + }), + ).rejects.toBeInstanceOf(GatewayMutationIndeterminateError); + + expect(writes).toEqual([[...current, ...desired]]); + }, + ); +}); diff --git a/src/core/gatewayExecutionRole.ts b/src/core/gatewayExecutionRole.ts new file mode 100644 index 000000000..01833b1db --- /dev/null +++ b/src/core/gatewayExecutionRole.ts @@ -0,0 +1,271 @@ +import { createHash } from "node:crypto"; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + GetRoleCommand, + GetRolePolicyCommand, + PutRolePolicyCommand, + type IAMClient, + type Role, + type Tag, +} from "@aws-sdk/client-iam"; +import { AgentCoreCLIError, ERROR_SOURCE } from "../errors"; +import type { GatewayPolicyStatement } from "./gatewayPolicy"; + +const POLICY_NAME = "AgentCoreCliGatewayExecutionPolicy"; +const ROLE_PREFIX = "AgentCoreCliGateway-"; +const ROLE_TAGS = { + managed: "AgentCoreCLIManaged", + resourceType: "AgentCoreCLIResourceType", + region: "AgentCoreCLIRegion", + resourceName: "AgentCoreCLIResourceName", +} as const; + +export type GatewayExecutionRoleOptions = { + propagationDelayMs?: number; + sleep?: (milliseconds: number) => Promise; +}; + +export type ManagedGatewayRole = { + arn: string; + name: string; + created: boolean; +}; + +export class GatewayMutationIndeterminateError extends AgentCoreCLIError { + constructor(resource: string, options?: ErrorOptions) { + super(`The outcome of the ${resource} mutation could not be determined`, { + ...options, + source: ERROR_SOURCE.SERVICE, + }); + } +} + +export class GatewayMutationTerminalError extends AgentCoreCLIError { + constructor( + readonly resource: string, + readonly status: string, + readonly statusReasons: readonly string[], + ) { + super(`${resource} reached ${status}: ${statusReasons.join(", ")}`, { + source: ERROR_SOURCE.SERVICE, + }); + } +} + +export class GatewayExecutionRole { + private readonly propagationDelayMs: number; + private readonly sleep: (milliseconds: number) => Promise; + + constructor( + private readonly iam: IAMClient, + options: GatewayExecutionRoleOptions = {}, + ) { + this.propagationDelayMs = options.propagationDelayMs ?? 10_000; + this.sleep = + options.sleep ?? + ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + } + + async ensure(gatewayName: string, region: string): Promise { + const roleName = gatewayRoleName(gatewayName, region); + try { + const response = await this.iam.send(new GetRoleCommand({ RoleName: roleName })); + if (!response.Role?.Arn) throw new Error(`IAM returned no ARN for role ${roleName}`); + if (!isOwnedRole(response.Role, gatewayName, region)) { + throw new Error( + `IAM role ${roleName} already exists but is not tagged as managed by the AgentCore CLI`, + ); + } + return { arn: response.Role.Arn, name: roleName, created: false }; + } catch (error) { + if ((error as Error).name !== "NoSuchEntityException") throw error; + } + + const response = await this.iam.send( + new CreateRoleCommand({ + RoleName: roleName, + Tags: gatewayRoleTags(gatewayName, region), + AssumeRolePolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }), + }), + ); + if (!response.Role?.Arn) throw new Error(`IAM returned no ARN for role ${roleName}`); + return { arn: response.Role.Arn, name: roleName, created: true }; + } + + async isManaged(gatewayName: string, region: string, roleArn: string): Promise { + if (!matchesGatewayExecutionRole(gatewayName, region, roleArn)) return false; + const roleName = roleNameFromArn(roleArn); + + try { + const response = await this.iam.send(new GetRoleCommand({ RoleName: roleName })); + return response.Role?.Arn === roleArn && isOwnedRole(response.Role, gatewayName, region); + } catch (error) { + if ( + ["NoSuchEntityException", "AccessDenied", "AccessDeniedException"].includes( + (error as Error).name, + ) + ) { + return false; + } + throw error; + } + } + + async rollbackCreate(role: ManagedGatewayRole): Promise { + if (!role.created) return; + await this.write(role.name, []); + await this.iam.send(new DeleteRoleCommand({ RoleName: role.name })); + } + + async read(roleArn: string): Promise { + try { + const response = await this.iam.send( + new GetRolePolicyCommand({ + RoleName: roleNameFromArn(roleArn), + PolicyName: POLICY_NAME, + }), + ); + if (!response.PolicyDocument) return []; + const document = parsePolicy(response.PolicyDocument); + return Array.isArray(document.Statement) ? document.Statement : []; + } catch (error) { + if ((error as Error).name === "NoSuchEntityException") return []; + throw error; + } + } + + async update( + roleArn: string, + current: GatewayPolicyStatement[], + desired: GatewayPolicyStatement[], + operation: { + mutate: () => Promise; + stabilize: () => Promise; + }, + options: { forcePropagation?: boolean } = {}, + ): Promise { + const roleName = roleNameFromArn(roleArn); + const transition = uniqueStatements([...current, ...desired]); + const staged = JSON.stringify(transition) !== JSON.stringify(current); + if (staged) await this.write(roleName, transition); + if ((staged || options.forcePropagation) && this.propagationDelayMs > 0) { + await this.sleep(this.propagationDelayMs); + } + + let value: T; + try { + value = await operation.mutate(); + } catch (error) { + if (!(error instanceof GatewayMutationIndeterminateError)) { + await this.write(roleName, current); + } + throw error; + } + try { + await operation.stabilize(); + } catch (error) { + if (error instanceof GatewayMutationTerminalError) { + await this.write(roleName, current); + } + throw error; + } + if (!staged || JSON.stringify(transition) !== JSON.stringify(desired)) { + await this.write(roleName, desired); + } + return value; + } + + async replace(roleArn: string, desired: GatewayPolicyStatement[]): Promise { + await this.write(roleNameFromArn(roleArn), desired); + } + + private async write(roleName: string, statements: GatewayPolicyStatement[]): Promise { + if (statements.length === 0) { + try { + await this.iam.send( + new DeleteRolePolicyCommand({ RoleName: roleName, PolicyName: POLICY_NAME }), + ); + } catch (error) { + if ((error as Error).name !== "NoSuchEntityException") throw error; + } + return; + } + + await this.iam.send( + new PutRolePolicyCommand({ + RoleName: roleName, + PolicyName: POLICY_NAME, + PolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: statements }), + }), + ); + } +} + +function parsePolicy(document: string): { Statement?: GatewayPolicyStatement[] } { + try { + return JSON.parse(document); + } catch { + return JSON.parse(decodeURIComponent(document)); + } +} + +export function gatewayRoleName(gatewayName: string, region: string): string { + const fullName = `${ROLE_PREFIX}${region}-${gatewayName}`; + if (fullName.length <= 64) return fullName; + const hash = createHash("sha256").update(`${region}:${gatewayName}`).digest("hex").slice(0, 8); + return `${fullName.slice(0, 55)}-${hash}`; +} + +export function matchesGatewayExecutionRole( + gatewayName: string, + region: string, + roleArn: string, +): boolean { + try { + return roleNameFromArn(roleArn) === gatewayRoleName(gatewayName, region); + } catch { + return false; + } +} + +function gatewayRoleTags(gatewayName: string, region: string): Tag[] { + return [ + { Key: ROLE_TAGS.managed, Value: "true" }, + { Key: ROLE_TAGS.resourceType, Value: "Gateway" }, + { Key: ROLE_TAGS.region, Value: region }, + { Key: ROLE_TAGS.resourceName, Value: gatewayName }, + ]; +} + +function isOwnedRole(role: Role, gatewayName: string, region: string): boolean { + const tags = new Map((role.Tags ?? []).map(({ Key, Value }) => [Key, Value])); + return ( + tags.get(ROLE_TAGS.managed) === "true" && + tags.get(ROLE_TAGS.resourceType) === "Gateway" && + tags.get(ROLE_TAGS.region) === region && + tags.get(ROLE_TAGS.resourceName) === gatewayName + ); +} + +function uniqueStatements(statements: GatewayPolicyStatement[]): GatewayPolicyStatement[] { + return [ + ...new Map(statements.map((statement) => [JSON.stringify(statement), statement])).values(), + ]; +} + +function roleNameFromArn(roleArn: string): string { + const roleName = roleArn.split("/").at(-1); + if (!roleName) throw new Error(`Invalid IAM role ARN: ${roleArn}`); + return roleName; +} diff --git a/src/core/gatewayPolicy.test.ts b/src/core/gatewayPolicy.test.ts new file mode 100644 index 000000000..bf60cbdf8 --- /dev/null +++ b/src/core/gatewayPolicy.test.ts @@ -0,0 +1,354 @@ +import { expect, test } from "bun:test"; +import { gatewayPolicy } from "./gatewayPolicy"; + +const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/orders"; +const ENGINE_ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/orders"; +const LAMBDA_ARN = "arn:aws:lambda:us-west-2:123456789012:function:orders"; +const INTERCEPTOR_ARN = "arn:aws:lambda:us-west-2:123456789012:function:interceptor"; +const TRANSFORM_ARN = "arn:aws:lambda:us-west-2:123456789012:function:transform"; +const WORKLOAD_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default/workload-identity/orders"; +const API_KEY_PROVIDER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/apikeycredentialprovider/orders"; +const OAUTH_PROVIDER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/oauth2credentialprovider/orders"; +const API_KEY_SECRET_ARN = "arn:aws:secretsmanager:us-west-2:123456789012:secret:api-key"; +const OAUTH_SECRET_ARN = "arn:aws:secretsmanager:us-west-2:123456789012:secret:oauth"; +const TOKEN_VAULT_KEY_ARN = "arn:aws:kms:us-west-2:123456789012:key/token-vault"; + +test("builds exact grants for every inferable Gateway permission family", () => { + expect( + gatewayPolicy({ + gatewayArn: GATEWAY_ARN, + workloadIdentityArn: WORKLOAD_ARN, + policyEngineArn: ENGINE_ARN, + credentialSecrets: new Map([ + [API_KEY_PROVIDER_ARN, API_KEY_SECRET_ARN], + [OAUTH_PROVIDER_ARN, OAUTH_SECRET_ARN], + ]), + tokenVaultKmsKeyArn: TOKEN_VAULT_KEY_ARN, + interceptorConfigurations: [ + { + interceptor: { lambda: { arn: INTERCEPTOR_ARN } }, + interceptionPoints: ["REQUEST"], + }, + ], + customTransformConfiguration: { lambda: { arn: TRANSFORM_ARN } }, + targets: [ + { + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: LAMBDA_ARN, + toolSchema: { inlinePayload: [] }, + }, + }, + }, + }, + { + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "web-search" }, + }, + }, + }, + }, + { + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "bedrock-knowledge-bases" }, + configurations: [ + { + name: "Retrieve", + parameterValues: { knowledgeBaseId: "KB12345678" }, + }, + { + name: "AgenticRetrieveStream", + parameterValues: { + retrievers: [ + { + configuration: { + knowledgeBase: { knowledgeBaseId: "KB87654321" }, + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + { + targetConfiguration: { + inference: { + connector: { source: { connectorId: "bedrock-mantle" } }, + }, + }, + }, + { + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: "api123", + stage: "prod", + apiGatewayToolConfiguration: { toolFilters: [] }, + }, + }, + }, + }, + { + targetConfiguration: { + http: { + agentcoreRuntime: { + arn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders", + schema: { source: { s3: { uri: "s3://schemas/runtime.json" } } }, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + { + targetConfiguration: { + mcp: { + mcpServer: { + endpoint: "https://example.test/mcp", + mcpToolSchema: { s3: { uri: "s3://schemas/mcp.json" } }, + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "JWT_PASSTHROUGH" }], + }, + { + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.test/api-key" } }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "API_KEY", + credentialProvider: { + apiKeyCredentialProvider: { + providerArn: API_KEY_PROVIDER_ARN, + }, + }, + }, + ], + }, + { + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.test/oauth" } }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: "OAUTH", + credentialProvider: { + oauthCredentialProvider: { + providerArn: OAUTH_PROVIDER_ARN, + scopes: ["orders.read"], + grantType: "AUTHORIZATION_CODE", + }, + }, + }, + ], + }, + ], + }), + ).toEqual([ + { + Effect: "Allow", + Action: ["lambda:InvokeFunction"], + Resource: [INTERCEPTOR_ARN], + }, + { + Effect: "Allow", + Action: ["lambda:InvokeFunction"], + Resource: [TRANSFORM_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:InvokeGateway"], + Resource: [GATEWAY_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetPolicyEngine"], + Resource: [ENGINE_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:AuthorizeAction", "bedrock-agentcore:PartiallyAuthorizeActions"], + Resource: [ENGINE_ARN, GATEWAY_ARN], + }, + { + Effect: "Allow", + Action: ["lambda:InvokeFunction"], + Resource: [LAMBDA_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:InvokeWebSearch"], + Resource: [ + "arn:aws:bedrock-agentcore::aws:tool/web-search.v1", + "arn:aws:bedrock-agentcore:us-west-2:aws:tool/web-search.v1", + ], + }, + { + Effect: "Allow", + Action: ["bedrock:GetKnowledgeBase"], + Resource: [ + "arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB12345678", + "arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB87654321", + ], + }, + { + Effect: "Allow", + Action: ["bedrock:Retrieve"], + Resource: ["arn:aws:bedrock:us-west-2:123456789012:knowledge-base/KB12345678"], + }, + { + Effect: "Allow", + Action: ["bedrock:AgenticRetrieveStream"], + Resource: ["*"], + }, + { + Effect: "Allow", + Action: ["bedrock-mantle:CreateInference"], + Resource: ["arn:aws:bedrock-mantle:us-west-2:123456789012:project/*"], + }, + { + Effect: "Allow", + Action: ["bedrock-mantle:ListModels"], + Resource: ["arn:aws:bedrock-mantle:us-west-2:123456789012:project/default"], + }, + { + Effect: "Allow", + Action: ["bedrock-mantle:CallWithBearerToken"], + Resource: ["*"], + }, + { + Effect: "Allow", + Action: ["execute-api:Invoke"], + Resource: ["arn:aws:execute-api:us-west-2:123456789012:api123/prod/*/*"], + }, + { + Effect: "Allow", + Action: ["s3:GetObject"], + Resource: ["arn:aws:s3:::schemas/runtime.json"], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:InvokeAgentRuntime"], + Resource: ["arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders"], + }, + { + Effect: "Allow", + Action: ["s3:GetObject"], + Resource: ["arn:aws:s3:::schemas/mcp.json"], + }, + { + Effect: "Allow", + Action: ["kms:Decrypt"], + Resource: [TOKEN_VAULT_KEY_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetWorkloadAccessToken"], + Resource: [ + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default", + WORKLOAD_ARN, + ], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetResourceApiKey"], + Resource: [ + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default", + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default", + WORKLOAD_ARN, + API_KEY_PROVIDER_ARN, + ], + }, + { + Effect: "Allow", + Action: ["secretsmanager:GetSecretValue"], + Resource: [API_KEY_SECRET_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetWorkloadAccessTokenForJWT"], + Resource: [ + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default", + WORKLOAD_ARN, + ], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetResourceOauth2Token"], + Resource: [ + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default", + "arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default", + WORKLOAD_ARN, + OAUTH_PROVIDER_ARN, + ], + }, + { + Effect: "Allow", + Action: ["secretsmanager:GetSecretValue"], + Resource: [OAUTH_SECRET_ARN], + }, + ]); +}); + +test("rejects an IAM-signed external endpoint whose permissions cannot be inferred", () => { + expect(() => + gatewayPolicy({ + gatewayArn: GATEWAY_ARN, + targets: [ + { + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://example.test", + protocolType: "CUSTOM", + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }, + ], + }), + ).toThrow(/manage this role externally/); +}); + +test("stages an account-scoped Gateway wildcard before create returns its ARN", () => { + expect(gatewayPolicy({ policyEngineArn: ENGINE_ARN })).toEqual([ + { + Effect: "Allow", + Action: ["bedrock-agentcore:GetPolicyEngine"], + Resource: [ENGINE_ARN], + }, + { + Effect: "Allow", + Action: ["bedrock-agentcore:AuthorizeAction", "bedrock-agentcore:PartiallyAuthorizeActions"], + Resource: [ENGINE_ARN, "arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/*"], + }, + ]); +}); + +test("omits the Token Vault KMS grant when no Target uses stored credentials", () => { + expect( + gatewayPolicy({ + tokenVaultKmsKeyArn: TOKEN_VAULT_KEY_ARN, + targets: [ + { + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.test/mcp" } }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "JWT_PASSTHROUGH" }], + }, + ], + }), + ).toEqual([]); +}); diff --git a/src/core/gatewayPolicy.ts b/src/core/gatewayPolicy.ts new file mode 100644 index 000000000..d88296c80 --- /dev/null +++ b/src/core/gatewayPolicy.ts @@ -0,0 +1,373 @@ +import type { + CredentialProviderConfiguration, + CustomTransformConfiguration, + GatewayInterceptorConfiguration, + TargetConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; + +export type GatewayPolicyStatement = { + Effect: "Allow"; + Action: string[]; + Resource: string[]; + Condition?: Record; +}; + +export type GatewayPolicyState = { + gatewayArn?: string; + workloadIdentityArn?: string; + policyEngineArn?: string; + interceptorConfigurations?: readonly GatewayInterceptorConfiguration[]; + customTransformConfiguration?: CustomTransformConfiguration; + credentialSecrets?: ReadonlyMap; + tokenVaultKmsKeyArn?: string; + targets?: readonly GatewayPolicyTarget[]; +}; + +export type GatewayPolicyTarget = { + targetConfiguration: TargetConfiguration; + credentialProviderConfigurations?: readonly CredentialProviderConfiguration[]; +}; + +export function gatewayPolicy(state: GatewayPolicyState): GatewayPolicyStatement[] { + const statements: GatewayPolicyStatement[] = []; + + for (const interceptor of state.interceptorConfigurations ?? []) { + const arn = interceptor.interceptor?.lambda?.arn; + if (!arn) throw unsupported("Gateway interceptor is missing its Lambda ARN"); + statements.push(allow("lambda:InvokeFunction", arn)); + } + if (state.customTransformConfiguration) { + const arn = state.customTransformConfiguration.lambda?.arn; + if (!arn) throw unsupported("Gateway custom transform is missing its Lambda ARN"); + statements.push(allow("lambda:InvokeFunction", arn)); + } + + if (state.gatewayArn) { + statements.push(allow("bedrock-agentcore:InvokeGateway", state.gatewayArn)); + } + + if (state.policyEngineArn) { + statements.push(allow("bedrock-agentcore:GetPolicyEngine", state.policyEngineArn)); + statements.push({ + Effect: "Allow", + Action: ["bedrock-agentcore:AuthorizeAction", "bedrock-agentcore:PartiallyAuthorizeActions"], + Resource: [state.policyEngineArn, state.gatewayArn ?? gatewayWildcard(state.policyEngineArn)], + }); + } + for (const target of state.targets ?? []) { + const configuration = target.targetConfiguration; + if (containsUnknown(configuration)) throw unsupported("Gateway Target type is not supported"); + const credentialTypes = (target.credentialProviderConfigurations ?? []).map( + ({ credentialProviderType }) => credentialProviderType, + ); + for (const credential of target.credentialProviderConfigurations ?? []) { + const apiKey = credential.credentialProviderType === "API_KEY"; + const oauth = credential.credentialProviderType === "OAUTH"; + if (!apiKey && !oauth) continue; + const providerArn = apiKey + ? credential.credentialProvider?.apiKeyCredentialProvider?.providerArn + : credential.credentialProvider?.oauthCredentialProvider?.providerArn; + if (!providerArn) throw unsupported("Credential provider ARN is missing"); + const secretArn = state.credentialSecrets?.get(providerArn); + if (!secretArn) throw unsupported(`Credential provider ${providerArn} was not resolved`); + const workloadArns = workloadIdentityResources(state.workloadIdentityArn); + const grantType = credential.credentialProvider?.oauthCredentialProvider?.grantType; + if (state.tokenVaultKmsKeyArn) { + statements.push(allow("kms:Decrypt", state.tokenVaultKmsKeyArn)); + } + statements.push( + { + Effect: "Allow", + Action: [ + oauth && grantType && grantType !== "CLIENT_CREDENTIALS" + ? "bedrock-agentcore:GetWorkloadAccessTokenForJWT" + : "bedrock-agentcore:GetWorkloadAccessToken", + ], + Resource: workloadArns, + }, + { + Effect: "Allow", + Action: [ + apiKey + ? "bedrock-agentcore:GetResourceApiKey" + : "bedrock-agentcore:GetResourceOauth2Token", + ], + Resource: credentialProviderResources(providerArn, workloadArns), + }, + allow("secretsmanager:GetSecretValue", secretArn), + ); + } + for (const schema of targetSchemas(configuration)) { + const uri = schema?.s3?.uri; + if (uri) statements.push(allow("s3:GetObject", s3Arn(uri, state.gatewayArn))); + } + + const lambdaArn = configuration.mcp?.lambda?.lambdaArn; + if (lambdaArn) { + requireCredentials(credentialTypes, "GATEWAY_IAM_ROLE"); + statements.push(allow("lambda:InvokeFunction", lambdaArn)); + continue; + } + + const connectorId = + configuration.mcp?.connector?.source?.connectorId ?? + configuration.inference?.connector?.source?.connectorId; + if (connectorId === "web-search") { + requireCredentials(credentialTypes, "GATEWAY_IAM_ROLE"); + if (!state.gatewayArn) throw new Error("Gateway ARN is required for Web Search permissions"); + const [prefix, partition, service, region] = state.gatewayArn.split(":"); + if (prefix !== "arn" || !partition || service !== "bedrock-agentcore" || !region) { + throw new Error(`Invalid Gateway ARN: ${state.gatewayArn}`); + } + statements.push({ + Effect: "Allow", + Action: ["bedrock-agentcore:InvokeWebSearch"], + Resource: [ + `arn:${partition}:${service}::aws:tool/web-search.v1`, + `arn:${partition}:${service}:${region}:aws:tool/web-search.v1`, + ], + }); + continue; + } + if (connectorId === "bedrock-knowledge-bases") { + requireCredentials(credentialTypes, "GATEWAY_IAM_ROLE"); + statements.push(...knowledgeBasePolicy(configuration, state.gatewayArn)); + continue; + } + if (connectorId === "bedrock-mantle") { + requireCredentials(credentialTypes, "GATEWAY_IAM_ROLE"); + const { partition, region, accountId } = gatewayContext(state.gatewayArn); + statements.push( + allow( + "bedrock-mantle:CreateInference", + `arn:${partition}:bedrock-mantle:${region}:${accountId}:project/*`, + ), + allow( + "bedrock-mantle:ListModels", + `arn:${partition}:bedrock-mantle:${region}:${accountId}:project/default`, + ), + allow("bedrock-mantle:CallWithBearerToken", "*"), + ); + continue; + } + + const apiGateway = configuration.mcp?.apiGateway; + if (apiGateway) { + requireCredentials(credentialTypes, "GATEWAY_IAM_ROLE", "API_KEY"); + if (!credentialTypes.includes("API_KEY")) { + const { partition, region, accountId } = gatewayContext(state.gatewayArn); + statements.push( + allow( + "execute-api:Invoke", + `arn:${partition}:execute-api:${region}:${accountId}:${apiGateway.restApiId}/${apiGateway.stage}/*/*`, + ), + ); + } + continue; + } + + const runtime = configuration.http?.agentcoreRuntime; + if (runtime) { + requireCredentials( + credentialTypes, + "GATEWAY_IAM_ROLE", + "CALLER_IAM_CREDENTIALS", + "JWT_PASSTHROUGH", + "OAUTH", + ); + if ( + !credentialTypes.some( + (type) => + type === "CALLER_IAM_CREDENTIALS" || type === "JWT_PASSTHROUGH" || type === "OAUTH", + ) + ) { + if (!runtime.arn) throw unsupported("AgentCore Runtime Target is missing its ARN"); + statements.push(allow("bedrock-agentcore:InvokeAgentRuntime", runtime.arn)); + } + continue; + } + + if ( + configuration.mcp?.mcpServer || + configuration.mcp?.openApiSchema || + configuration.mcp?.smithyModel || + configuration.http?.passthrough || + configuration.inference?.connector || + configuration.inference?.provider + ) { + requireCredentials( + credentialTypes, + "CALLER_IAM_CREDENTIALS", + "JWT_PASSTHROUGH", + "API_KEY", + "OAUTH", + ); + continue; + } + + throw unsupported("Gateway Target permissions cannot be inferred"); + } + + return [ + ...new Map( + statements.map((statement) => [JSON.stringify(statement), statement] as const), + ).values(), + ]; +} + +function workloadIdentityResources(workloadIdentityArn: string | undefined): string[] { + const separator = "/workload-identity/"; + const separatorIndex = workloadIdentityArn?.indexOf(separator) ?? -1; + if (!workloadIdentityArn || separatorIndex < 0) { + throw unsupported("Gateway workload identity ARN is missing"); + } + return [workloadIdentityArn.slice(0, separatorIndex), workloadIdentityArn]; +} + +function credentialProviderResources(providerArn: string, workloadArns: string[]): string[] { + const providerMarker = providerArn.includes("/apikeycredentialprovider/") + ? "/apikeycredentialprovider/" + : "/oauth2credentialprovider/"; + const providerIndex = providerArn.indexOf(providerMarker); + if (providerIndex < 0) throw unsupported(`Invalid credential provider ARN ${providerArn}`); + return [providerArn.slice(0, providerIndex), ...workloadArns, providerArn]; +} + +function allow(action: string, resource: string): GatewayPolicyStatement { + return { Effect: "Allow", Action: [action], Resource: [resource] }; +} + +function requireCredentials( + actual: readonly (string | undefined)[], + ...allowed: readonly string[] +): void { + if (actual.some((type) => !type || !allowed.includes(type))) { + const unsupportedType = actual.find((type) => !type || !allowed.includes(type)); + throw unsupported(`Credential provider ${unsupportedType ?? "unknown"} is not supported`); + } +} + +function targetSchemas( + configuration: TargetConfiguration, +): Array<{ s3?: { uri?: string } } | undefined> { + return [ + configuration.mcp?.lambda?.toolSchema, + configuration.mcp?.mcpServer?.mcpToolSchema, + configuration.mcp?.openApiSchema, + configuration.mcp?.smithyModel, + configuration.http?.agentcoreRuntime?.schema?.source, + configuration.http?.passthrough?.schema?.source, + ]; +} + +function s3Arn(uri: string, gatewayArn: string | undefined): string { + const match = uri.match(/^s3:\/\/([^/]+)\/(.+)$/); + if (!match?.[1] || !match[2]) throw unsupported(`Invalid S3 schema URI "${uri}"`); + const { partition } = gatewayContext(gatewayArn); + return `arn:${partition}:s3:::${match[1]}/${match[2]}`; +} + +function knowledgeBasePolicy( + configuration: TargetConfiguration, + gatewayArn: string | undefined, +): GatewayPolicyStatement[] { + const connector = configuration.mcp?.connector; + const { partition, region, accountId } = gatewayContext(gatewayArn); + const all = new Set(); + const retrieve = new Set(); + let agentic = false; + + for (const tool of connector?.configurations ?? []) { + if (tool.name === "Retrieve") { + const id = nestedString(tool.parameterValues, "knowledgeBaseId"); + if (!id) throw unsupported("Knowledge Base Retrieve tool is missing knowledgeBaseId"); + all.add(id); + retrieve.add(id); + continue; + } + if (tool.name === "AgenticRetrieveStream") { + const retrievers = nestedArray(tool.parameterValues, "retrievers"); + if (!retrievers?.length) { + throw unsupported("Knowledge Base AgenticRetrieveStream tool has no retrievers"); + } + for (const retriever of retrievers) { + const id = nestedString(retriever, "configuration", "knowledgeBase", "knowledgeBaseId"); + if (!id) throw unsupported("Knowledge Base retriever is missing knowledgeBaseId"); + all.add(id); + } + agentic = true; + continue; + } + throw unsupported(`Knowledge Base tool ${tool.name ?? "unknown"} is not supported`); + } + if (all.size === 0) throw unsupported("Knowledge Base connector has no configured tools"); + + const arn = (id: string) => + `arn:${partition}:bedrock:${region}:${accountId}:knowledge-base/${id}`; + return [ + { + Effect: "Allow", + Action: ["bedrock:GetKnowledgeBase"], + Resource: [...all].sort().map(arn), + }, + ...(retrieve.size > 0 + ? [ + { + Effect: "Allow" as const, + Action: ["bedrock:Retrieve"], + Resource: [...retrieve].sort().map(arn), + }, + ] + : []), + ...(agentic ? [allow("bedrock:AgenticRetrieveStream", "*")] : []), + ]; +} + +function nestedString(value: unknown, ...path: readonly string[]): string | undefined { + let current = value; + for (const key of path) { + if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Record)[key]; + } + return typeof current === "string" && current ? current : undefined; +} + +function nestedArray(value: unknown, ...path: readonly string[]): unknown[] | undefined { + let current = value; + for (const key of path) { + if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Record)[key]; + } + return Array.isArray(current) ? current : undefined; +} + +function containsUnknown(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsUnknown); + if (!value || typeof value !== "object") return false; + const record = value as Record; + return "$unknown" in record || Object.values(record).some(containsUnknown); +} + +function gatewayContext(gatewayArn: string | undefined): { + partition: string; + region: string; + accountId: string; +} { + const [prefix, partition, service, region, accountId] = gatewayArn?.split(":") ?? []; + if (prefix !== "arn" || !partition || service !== "bedrock-agentcore" || !region || !accountId) { + throw unsupported(`Invalid Gateway ARN "${gatewayArn ?? ""}"`); + } + return { partition, region, accountId }; +} + +function unsupported(message: string): Error { + return new Error(`${message}; manage this role externally for this operation`); +} + +function gatewayWildcard(policyEngineArn: string): string { + const [prefix, partition, service, region, accountId] = policyEngineArn.split(":"); + if (prefix !== "arn" || !partition || service !== "bedrock-agentcore" || !region || !accountId) { + throw new Error(`Invalid Policy Engine ARN: ${policyEngineArn}`); + } + return `arn:${partition}:${service}:${region}:${accountId}:gateway/*`; +} diff --git a/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.e015bbd46d1859f1.json b/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.e015bbd46d1859f1.json new file mode 100644 index 000000000..fed4210d2 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/GetGatewayCommand.e015bbd46d1859f1.json @@ -0,0 +1,19 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "gatewayId": "agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "createdAt": { + "$date": "2026-08-07T17:54:42.000Z" + }, + "updatedAt": { + "$date": "2026-08-07T17:54:43.000Z" + }, + "status": "READY", + "name": "agentcore-cli-gateway-delete-fixture", + "authorizerType": "NONE", + "gatewayUrl": "https://agentcore-cli-gateway-delete-fixture-oiemu02wfc.gateway.bedrock-agentcore.us-east-1.amazonaws.com", + "description": "Disposable Gateway Delete fixture", + "roleArn": "arn:aws:iam::685197708687:role/AgentCoreCliGatewayDeleteFixtureRole", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:workload-identity-directory/default/workload-identity/agentcore-cli-gateway-delete-fixture-oiemu02wfc" + } +} diff --git a/src/handlers/gateway/connector/create/index.tsx b/src/handlers/gateway/connector/create/index.tsx index 329479a1b..e22d77fcc 100644 --- a/src/handlers/gateway/connector/create/index.tsx +++ b/src/handlers/gateway/connector/create/index.tsx @@ -12,6 +12,7 @@ import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; import type { CreateGatewayTargetInput } from "../../types"; +import { warnForGatewayRolePolicyUpdate } from "../../rolePolicyWarning"; import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; export const createCreateGatewayConnectorHandler = (core: Core, io: AppIO) => @@ -52,6 +53,7 @@ export const createCreateGatewayConnectorHandler = (core: Core, io: AppIO) => "private endpoint (JSON; inline, file://, or - for stdin)", z.string().optional(), ), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -133,10 +135,19 @@ export const createCreateGatewayConnectorHandler = (core: Core, io: AppIO) => credentialProviderConfigurations, ...(metadataConfiguration ? { metadataConfiguration } : {}), ...(privateEndpoint ? { privateEndpoint } : {}), + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), }; + const options = coreOptsFromCtx(ctx); + await warnForGatewayRolePolicyUpdate( + core, + io, + flags["gateway-id"], + options, + flags["skip-role-policy-update"], + ); ctx .require(JsonRendererKey) - .renderJson(await core.gateway.createGatewayTarget(input, coreOptsFromCtx(ctx))); + .renderJson(await core.gateway.createGatewayTarget(input, options)); }, }); diff --git a/src/handlers/gateway/connector/delete/index.tsx b/src/handlers/gateway/connector/delete/index.tsx index 52df6eba2..40e4c1cca 100644 --- a/src/handlers/gateway/connector/delete/index.tsx +++ b/src/handlers/gateway/connector/delete/index.tsx @@ -13,6 +13,7 @@ export const createDeleteGatewayConnectorHandler = (core: Core) => flags: [ flag("gateway-id", "the parent Gateway ID", z.string().optional()), flag("id", "the connector-backed Gateway Target ID", z.string().optional()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -27,8 +28,15 @@ export const createDeleteGatewayConnectorHandler = (core: Core) => if (!GatewayConnectorTarget.is(target.targetConfiguration)) { throw new InputValidationError(`Gateway Target "${flags.id}" is not connector-backed`); } - ctx - .require(JsonRendererKey) - .renderJson(await core.gateway.deleteGatewayTarget(flags["gateway-id"], flags.id, options)); + ctx.require(JsonRendererKey).renderJson( + await core.gateway.deleteGatewayTarget( + { + gatewayId: flags["gateway-id"], + targetId: flags.id, + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), + }, + options, + ), + ); }, }); diff --git a/src/handlers/gateway/connector/update/index.tsx b/src/handlers/gateway/connector/update/index.tsx index 4ec6d3927..c22b4929e 100644 --- a/src/handlers/gateway/connector/update/index.tsx +++ b/src/handlers/gateway/connector/update/index.tsx @@ -18,6 +18,7 @@ import { } from "../../../utils"; import type { GatewayTargetUpdatePatch } from "../../types"; import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; +import { warnForGatewayRolePolicyUpdate } from "../../rolePolicyWarning"; export const createUpdateGatewayConnectorHandler = (core: Core, io: AppIO) => createHandler({ @@ -62,6 +63,7 @@ export const createUpdateGatewayConnectorHandler = (core: Core, io: AppIO) => flag("clear-credential-provider-configurations", "remove outbound credentials", z.boolean()), flag("clear-metadata-configuration", "remove metadata propagation", z.boolean()), flag("clear-private-endpoint", "remove private endpoint configuration", z.boolean()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -162,10 +164,19 @@ export const createUpdateGatewayConnectorHandler = (core: Core, io: AppIO) => gatewayId: flags["gateway-id"], targetId: flags.id, ...mutations, + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), }; + const options = coreOptsFromCtx(ctx); + await warnForGatewayRolePolicyUpdate( + core, + io, + flags["gateway-id"], + options, + flags["skip-role-policy-update"], + ); ctx .require(JsonRendererKey) - .renderJson(await core.gateway.updateGatewayConnector(patch, coreOptsFromCtx(ctx))); + .renderJson(await core.gateway.updateGatewayConnector(patch, options)); }, }); diff --git a/src/handlers/gateway/create/index.tsx b/src/handlers/gateway/create/index.tsx index be0f56fa1..13866d161 100644 --- a/src/handlers/gateway/create/index.tsx +++ b/src/handlers/gateway/create/index.tsx @@ -5,7 +5,7 @@ import type { } from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { InputValidationError } from "../../../errors"; -import { type AppIO, SourceResolver } from "../../../io"; +import { type AppIO, SourceResolver, warn } from "../../../io"; import { createHandler, flag } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { Core } from "../../types"; @@ -64,9 +64,6 @@ export const createCreateGatewayHandler = (core: Core, io: AppIO) => if (!flags.name) { throw new InputValidationError("required option '--name ' not specified"); } - if (!flags["role-arn"]) { - throw new InputValidationError("required option '--role-arn ' not specified"); - } if (!flags["authorizer-type"]) { throw new InputValidationError( "required option '--authorizer-type ' not specified", @@ -120,7 +117,7 @@ export const createCreateGatewayHandler = (core: Core, io: AppIO) => const input: CreateGatewayInput = { name: flags.name, - roleArn: flags["role-arn"], + ...(flags["role-arn"] ? { roleArn: flags["role-arn"] } : {}), ...(flags.protocol ? { protocol: flags.protocol } : {}), authorizerType: flags["authorizer-type"], ...(flags.description ? { description: flags.description } : {}), @@ -134,6 +131,12 @@ export const createCreateGatewayHandler = (core: Core, io: AppIO) => ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), }; + if (flags["role-arn"]) { + warn( + io, + `Using customer-managed execution role ${flags["role-arn"]}; IAM policies will not be modified.`, + ); + } ctx .require(JsonRendererKey) .renderJson(await core.gateway.createGateway(input, coreOptsFromCtx(ctx))); diff --git a/src/handlers/gateway/gateway.create.test.tsx b/src/handlers/gateway/gateway.create.test.tsx index 9d920b925..894e94021 100644 --- a/src/handlers/gateway/gateway.create.test.tsx +++ b/src/handlers/gateway/gateway.create.test.tsx @@ -221,11 +221,6 @@ async function cleanup(state: FixtureState): Promise { describe("Gateway create validation", () => { test.each([ ["Gateway name", ["gateway", "create", "--authorizer-type", "NONE"], /--name/], - [ - "Gateway role", - ["gateway", "create", "--name", "orders", "--authorizer-type", "NONE"], - /--role-arn/, - ], [ "Gateway authorizer", ["gateway", "create", "--name", "orders", "--role-arn", TEST_ROLE_ARN], diff --git a/src/handlers/gateway/gateway.delete.test.tsx b/src/handlers/gateway/gateway.delete.test.tsx index 04795a026..2d025441a 100644 --- a/src/handlers/gateway/gateway.delete.test.tsx +++ b/src/handlers/gateway/gateway.delete.test.tsx @@ -85,7 +85,7 @@ describe("gateway delete commands", () => { expect(core.gateway.calls).toEqual([ { method: "deleteGatewayTarget", - args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + args: [{ gatewayId: GATEWAY_ID, targetId: TARGET_ID }, { region: REGION }], }, ]); expect(JSON.parse(result.stdout)).toEqual(response); @@ -115,7 +115,7 @@ describe("gateway delete commands", () => { }, { method: "deleteGatewayTarget", - args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + args: [{ gatewayId: GATEWAY_ID, targetId: TARGET_ID }, { region: REGION }], }, ]); expect(JSON.parse(result.stdout)).toEqual(response); diff --git a/src/handlers/gateway/gateway.update.test.tsx b/src/handlers/gateway/gateway.update.test.tsx index d27609816..bbf080aa7 100644 --- a/src/handlers/gateway/gateway.update.test.tsx +++ b/src/handlers/gateway/gateway.update.test.tsx @@ -144,6 +144,7 @@ describe("Gateway update patch mapping", () => { "--policy-engine-mode", "enforce", "--clear-exception-level", + "--skip-role-policy-update", ]); expect(core.gateway.calls.find((call) => call.method === "updateGateway")?.args[0]).toEqual({ @@ -152,6 +153,7 @@ describe("Gateway update patch mapping", () => { clearProtocol: true, policyEngineConfiguration: { mode: "ENFORCE" }, exceptionLevel: null, + skipRolePolicyUpdate: true, }); }); diff --git a/src/handlers/gateway/rolePolicyWarning.ts b/src/handlers/gateway/rolePolicyWarning.ts new file mode 100644 index 000000000..2ffd80681 --- /dev/null +++ b/src/handlers/gateway/rolePolicyWarning.ts @@ -0,0 +1,19 @@ +import { warn, type AppIO } from "../../io"; +import type { CoreOptions } from "../../core/types"; +import type { Core } from "../types"; + +export async function warnForGatewayRolePolicyUpdate( + core: Core, + io: AppIO, + gatewayId: string, + options: CoreOptions, + skip = false, +): Promise { + if (skip) return; + const roleArn = await core.gateway.getGatewayRolePolicyWarning(gatewayId, options); + if (!roleArn) return; + warn( + io, + `Execution role ${roleArn} is not managed by the AgentCore CLI. IAM policies will not be modified; you are responsible for permissions required by this operation.`, + ); +} diff --git a/src/handlers/gateway/target/create/index.tsx b/src/handlers/gateway/target/create/index.tsx index 7bfa7fd3c..0b2224cd1 100644 --- a/src/handlers/gateway/target/create/index.tsx +++ b/src/handlers/gateway/target/create/index.tsx @@ -13,6 +13,7 @@ import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx, parseJsonArrayFlag, parseJsonObjectFlag } from "../../../utils"; import type { CreateGatewayTargetInput } from "../../types"; +import { warnForGatewayRolePolicyUpdate } from "../../rolePolicyWarning"; export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => createHandler({ @@ -53,6 +54,7 @@ export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => z.string().optional(), ), flag("client-token", "idempotency token", z.string().optional()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -116,10 +118,19 @@ export const createCreateGatewayTargetHandler = (core: Core, io: AppIO) => ...(metadataConfiguration ? { metadataConfiguration } : {}), ...(privateEndpoint ? { privateEndpoint } : {}), ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), }; + const options = coreOptsFromCtx(ctx); + await warnForGatewayRolePolicyUpdate( + core, + io, + flags["gateway-id"], + options, + flags["skip-role-policy-update"], + ); ctx .require(JsonRendererKey) - .renderJson(await core.gateway.createGatewayTarget(input, coreOptsFromCtx(ctx))); + .renderJson(await core.gateway.createGatewayTarget(input, options)); }, }); diff --git a/src/handlers/gateway/target/delete/index.tsx b/src/handlers/gateway/target/delete/index.tsx index 75c3626fb..1575eb6e1 100644 --- a/src/handlers/gateway/target/delete/index.tsx +++ b/src/handlers/gateway/target/delete/index.tsx @@ -12,6 +12,7 @@ export const createDeleteGatewayTargetHandler = (core: Core) => flags: [ flag("gateway-id", "the parent Gateway ID", z.string().optional()), flag("target-id", "the Target ID", z.string().optional()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -20,14 +21,15 @@ export const createDeleteGatewayTargetHandler = (core: Core) => if (!flags["target-id"]) { throw new InputValidationError("required option '--target-id ' not specified"); } - ctx - .require(JsonRendererKey) - .renderJson( - await core.gateway.deleteGatewayTarget( - flags["gateway-id"], - flags["target-id"], - coreOptsFromCtx(ctx), - ), - ); + ctx.require(JsonRendererKey).renderJson( + await core.gateway.deleteGatewayTarget( + { + gatewayId: flags["gateway-id"], + targetId: flags["target-id"], + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), + }, + coreOptsFromCtx(ctx), + ), + ); }, }); diff --git a/src/handlers/gateway/target/update/index.tsx b/src/handlers/gateway/target/update/index.tsx index b2f9983b7..989e903c6 100644 --- a/src/handlers/gateway/target/update/index.tsx +++ b/src/handlers/gateway/target/update/index.tsx @@ -17,6 +17,7 @@ import { parseJsonObjectFlag, } from "../../../utils"; import type { GatewayTargetUpdatePatch } from "../../types"; +import { warnForGatewayRolePolicyUpdate } from "../../rolePolicyWarning"; export const createUpdateGatewayTargetHandler = (core: Core, io: AppIO) => createHandler({ @@ -52,6 +53,7 @@ export const createUpdateGatewayTargetHandler = (core: Core, io: AppIO) => flag("clear-credential-provider-configurations", "remove outbound credentials", z.boolean()), flag("clear-metadata-configuration", "remove metadata propagation", z.boolean()), flag("clear-private-endpoint", "remove private endpoint configuration", z.boolean()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags["gateway-id"]) { @@ -128,10 +130,19 @@ export const createUpdateGatewayTargetHandler = (core: Core, io: AppIO) => gatewayId: flags["gateway-id"], targetId: flags["target-id"], ...mutations, + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), }; + const options = coreOptsFromCtx(ctx); + await warnForGatewayRolePolicyUpdate( + core, + io, + flags["gateway-id"], + options, + flags["skip-role-policy-update"], + ); ctx .require(JsonRendererKey) - .renderJson(await core.gateway.updateGatewayTarget(patch, coreOptsFromCtx(ctx))); + .renderJson(await core.gateway.updateGatewayTarget(patch, options)); }, }); diff --git a/src/handlers/gateway/types.tsx b/src/handlers/gateway/types.tsx index 7342d3fa6..e803682b4 100644 --- a/src/handlers/gateway/types.tsx +++ b/src/handlers/gateway/types.tsx @@ -25,17 +25,21 @@ import type { CoreOptions } from "../../core/types"; export type GatewayProtocol = "mcp"; -export type CreateGatewayInput = Omit & { +export type CreateGatewayInput = Omit & { protocol?: GatewayProtocol; + roleArn?: string; }; -export type CreateGatewayTargetInput = CreateGatewayTargetRequest; +export type CreateGatewayTargetInput = CreateGatewayTargetRequest & { + skipRolePolicyUpdate?: boolean; +}; export type CreateGatewayRuleInput = CreateGatewayRuleRequest; export type GatewayUpdatePatch = { id: string; roleArn?: UpdateGatewayRequest["roleArn"]; + skipRolePolicyUpdate?: boolean; clearProtocol?: boolean; description?: UpdateGatewayRequest["description"] | null; protocolConfiguration?: UpdateGatewayRequest["protocolConfiguration"] | null; @@ -52,6 +56,7 @@ export type GatewayUpdatePatch = { export type GatewayTargetUpdatePatch = { gatewayId: string; targetId: string; + skipRolePolicyUpdate?: boolean; name?: UpdateGatewayTargetRequest["name"]; description?: UpdateGatewayTargetRequest["description"] | null; endpoint?: string; @@ -62,9 +67,15 @@ export type GatewayTargetUpdatePatch = { privateEndpoint?: UpdateGatewayTargetRequest["privateEndpoint"] | null; }; +export type GatewayTargetDeleteInput = Pick< + GatewayTargetUpdatePatch, + "gatewayId" | "targetId" | "skipRolePolicyUpdate" +>; + 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; @@ -109,8 +120,7 @@ export interface CoreGatewayClient { options: CoreOptions, ): Promise; deleteGatewayTarget( - gatewayId: string, - targetId: string, + input: GatewayTargetDeleteInput, options: CoreOptions, ): Promise; getGatewayRule( diff --git a/src/handlers/gateway/update/index.tsx b/src/handlers/gateway/update/index.tsx index f0fd94918..d8e580604 100644 --- a/src/handlers/gateway/update/index.tsx +++ b/src/handlers/gateway/update/index.tsx @@ -18,6 +18,7 @@ import { parseJsonObjectFlag, } from "../../utils"; import type { GatewayUpdatePatch } from "../types"; +import { warnForGatewayRolePolicyUpdate } from "../rolePolicyWarning"; export const createUpdateGatewayHandler = (core: Core, io: AppIO) => createHandler({ @@ -71,6 +72,7 @@ export const createUpdateGatewayHandler = (core: Core, io: AppIO) => flag("clear-policy-engine", "detach the Policy Engine", z.boolean()), flag("clear-exception-level", "return to generic invocation errors", z.boolean()), flag("clear-waf-configuration", "reset WAF failure mode to FAIL_CLOSE", z.boolean()), + flag("skip-role-policy-update", "leave execution-role IAM policies unchanged", z.boolean()), ], handle: async (ctx, flags) => { if (!flags.id) { @@ -187,10 +189,22 @@ export const createUpdateGatewayHandler = (core: Core, io: AppIO) => if (Object.values(mutations).every((value) => value === undefined)) { throw new InputValidationError("Gateway update requires at least one mutation option"); } - const patch: GatewayUpdatePatch = { id: flags.id, ...mutations }; + const patch: GatewayUpdatePatch = { + id: flags.id, + ...mutations, + ...(flags["skip-role-policy-update"] ? { skipRolePolicyUpdate: true } : {}), + }; - ctx - .require(JsonRendererKey) - .renderJson(await core.gateway.updateGateway(patch, coreOptsFromCtx(ctx))); + const options = coreOptsFromCtx(ctx); + if (!flags["role-arn"]) { + await warnForGatewayRolePolicyUpdate( + core, + io, + flags.id, + options, + flags["skip-role-policy-update"], + ); + } + ctx.require(JsonRendererKey).renderJson(await core.gateway.updateGateway(patch, options)); }, }); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 999f7f8ed..19815176c 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -102,6 +102,7 @@ import type { CreateGatewayInput, CreateGatewayRuleInput, CreateGatewayTargetInput, + GatewayTargetDeleteInput, GatewayRuleUpdateInput, GatewayTargetUpdatePatch, GatewayUpdatePatch, @@ -877,6 +878,14 @@ export class TestGatewayClient implements CoreGatewayClient { private getRuleResponse: GetGatewayRuleResponse = DEFAULT_GET_GATEWAY_RULE_RESPONSE; private listRuleResponses = new Map(); private deleteRuleResponse: DeleteGatewayRuleResponse = DEFAULT_DELETE_GATEWAY_RULE_RESPONSE; + + async getGatewayRolePolicyWarning( + gatewayId: string, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "getGatewayRolePolicyWarning", args: [gatewayId, options] }); + return undefined; + } private error?: Error; setGetResponse(response: GetGatewayResponse): this { @@ -1067,11 +1076,10 @@ export class TestGatewayClient implements CoreGatewayClient { } async deleteGatewayTarget( - gatewayId: string, - targetId: string, + input: GatewayTargetDeleteInput, options: CoreOptions, ): Promise { - this.calls.push({ method: "deleteGatewayTarget", args: [gatewayId, targetId, options] }); + this.calls.push({ method: "deleteGatewayTarget", args: [input, options] }); if (this.error) throw this.error; return this.deleteTargetResponse; }