From eeb4f5ec8e5eb96c50d58709686a45400f7b3630 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Thu, 21 May 2026 07:39:42 +0000 Subject: [PATCH 01/11] chore(bootstrap): scaffold preflight directory for resource-action-map (#124) --- cdk/src/bootstrap/preflight/.gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 cdk/src/bootstrap/preflight/.gitkeep diff --git a/cdk/src/bootstrap/preflight/.gitkeep b/cdk/src/bootstrap/preflight/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/cdk/src/bootstrap/preflight/.gitkeep @@ -0,0 +1 @@ + From fe1778e92e39212265cb8075be24c724ce401622 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Thu, 21 May 2026 07:50:03 +0000 Subject: [PATCH 02/11] refactor(compute): gate ECS construct on compute_type context Replace comment toggle with proper context gate. ECS resources only synthesize when compute_type=ecs is passed. Default (agentcore) behavior unchanged. Closes #164 Co-Authored-By: Claude Opus 4.6 (1M context) --- cdk/src/bootstrap/preflight/.gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 cdk/src/bootstrap/preflight/.gitkeep diff --git a/cdk/src/bootstrap/preflight/.gitkeep b/cdk/src/bootstrap/preflight/.gitkeep deleted file mode 100644 index 8b1378917..000000000 --- a/cdk/src/bootstrap/preflight/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - From 0d7733a8f8862d814ccd5ab74d9ff5840d787451 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Thu, 21 May 2026 07:53:56 +0000 Subject: [PATCH 03/11] feat(bootstrap): add getRequiredBootstrapPolicies for compute-type-aware policy selection Co-Authored-By: Claude Opus 4.6 (1M context) --- cdk/src/bootstrap/required-policies.ts | 36 ++++++++++++++++ cdk/test/bootstrap/required-policies.test.ts | 44 ++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 cdk/src/bootstrap/required-policies.ts create mode 100644 cdk/test/bootstrap/required-policies.test.ts diff --git a/cdk/src/bootstrap/required-policies.ts b/cdk/src/bootstrap/required-policies.ts new file mode 100644 index 000000000..8d907e829 --- /dev/null +++ b/cdk/src/bootstrap/required-policies.ts @@ -0,0 +1,36 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const CORE_POLICIES = [ + 'infrastructure', + 'application', + 'observability', + 'compute-agentcore', +] as const; + +const COMPUTE_VARIANT_POLICIES: Record = { + ecs: ['compute-ecs'], +}; + +export function getRequiredBootstrapPolicies(computeType: string): string[] { + const base: string[] = [...CORE_POLICIES]; + const variants = COMPUTE_VARIANT_POLICIES[computeType]; + if (variants) base.push(...variants); + return base; +} diff --git a/cdk/test/bootstrap/required-policies.test.ts b/cdk/test/bootstrap/required-policies.test.ts new file mode 100644 index 000000000..9ef765f5a --- /dev/null +++ b/cdk/test/bootstrap/required-policies.test.ts @@ -0,0 +1,44 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { getRequiredBootstrapPolicies } from '../../src/bootstrap/required-policies'; + +describe('getRequiredBootstrapPolicies', () => { + it('returns core policies plus compute-agentcore for default', () => { + const result = getRequiredBootstrapPolicies('agentcore'); + expect(result).toEqual(['infrastructure', 'application', 'observability', 'compute-agentcore']); + }); + + it('includes compute-ecs when compute type is ecs', () => { + const result = getRequiredBootstrapPolicies('ecs'); + expect(result).toContain('compute-ecs'); + expect(result).toContain('compute-agentcore'); + }); + + it('always includes compute-agentcore regardless of type', () => { + const result = getRequiredBootstrapPolicies('ecs'); + expect(result).toContain('compute-agentcore'); + }); + + it('returns core policies for unknown compute type', () => { + const result = getRequiredBootstrapPolicies('unknown'); + expect(result).toEqual(['infrastructure', 'application', 'observability', 'compute-agentcore']); + expect(result).not.toContain('compute-ecs'); + }); +}); From 9f9c480ac69e0a4a3eb6aee01f900fdf717de8d5 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Thu, 21 May 2026 08:00:33 +0000 Subject: [PATCH 04/11] feat(bootstrap): add resource-action-map for 57 CF resource types Maps all CloudFormation resource types used by the ABCA stack to their required IAM actions per lifecycle phase (create/read/update/delete). Actions are sourced from CloudTrail-validated policies in DEPLOYMENT_ROLES.md. Tests validate structure, format, and policy coverage (with known gaps for SQS, S3 bucket lifecycle, and Lambda ESM/Layer actions documented). Co-Authored-By: Claude Opus 4.6 (1M context) --- cdk/src/bootstrap/preflight/index.ts | 25 + .../preflight/resource-action-map.ts | 428 ++++++++++++++++++ .../bootstrap/resource-action-map.test.ts | 186 ++++++++ 3 files changed, 639 insertions(+) create mode 100644 cdk/src/bootstrap/preflight/index.ts create mode 100644 cdk/src/bootstrap/preflight/resource-action-map.ts create mode 100644 cdk/test/bootstrap/resource-action-map.test.ts diff --git a/cdk/src/bootstrap/preflight/index.ts b/cdk/src/bootstrap/preflight/index.ts new file mode 100644 index 000000000..7d317acd9 --- /dev/null +++ b/cdk/src/bootstrap/preflight/index.ts @@ -0,0 +1,25 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export { + RESOURCE_ACTION_MAP, + getActionsForResource, + getAllMappedActions, +} from './resource-action-map'; +export type { ResourceActions } from './resource-action-map'; diff --git a/cdk/src/bootstrap/preflight/resource-action-map.ts b/cdk/src/bootstrap/preflight/resource-action-map.ts new file mode 100644 index 000000000..29a441a57 --- /dev/null +++ b/cdk/src/bootstrap/preflight/resource-action-map.ts @@ -0,0 +1,428 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Maps CloudFormation resource types to the IAM actions required for each + * lifecycle phase (create, read, update, delete). Actions are sourced from + * CloudTrail-validated policies in docs/design/DEPLOYMENT_ROLES.md. + */ + +export interface ResourceActions { + create: string[]; + read: string[]; + update: string[]; + delete: string[]; +} + +export const RESOURCE_ACTION_MAP: Record = { + // ─── API Gateway ──────────────────────────────────────────────────────────── + 'AWS::ApiGateway::Account': { + create: ['apigateway:PATCH'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH'], + delete: ['apigateway:PATCH'], + }, + 'AWS::ApiGateway::Authorizer': { + create: ['apigateway:POST'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::Deployment': { + create: ['apigateway:POST'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::Method': { + create: ['apigateway:PUT'], + read: ['apigateway:GET'], + update: ['apigateway:PUT'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::RequestValidator': { + create: ['apigateway:POST'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::Resource': { + create: ['apigateway:POST'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::RestApi': { + create: ['apigateway:POST', 'apigateway:TagResource'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH', 'apigateway:TagResource', 'apigateway:UntagResource'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::Stage': { + create: ['apigateway:POST', 'apigateway:TagResource'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH', 'apigateway:TagResource', 'apigateway:UntagResource'], + delete: ['apigateway:DELETE'], + }, + + // ─── Bedrock ──────────────────────────────────────────────────────────────── + 'AWS::Bedrock::Guardrail': { + create: ['bedrock:CreateGuardrail', 'bedrock:TagResource'], + read: ['bedrock:GetGuardrail', 'bedrock:ListTagsForResource'], + update: ['bedrock:UpdateGuardrail', 'bedrock:TagResource', 'bedrock:UntagResource'], + delete: ['bedrock:DeleteGuardrail'], + }, + 'AWS::Bedrock::GuardrailVersion': { + create: ['bedrock:CreateGuardrailVersion'], + read: ['bedrock:GetGuardrail'], + update: ['bedrock:CreateGuardrailVersion'], + delete: ['bedrock:DeleteGuardrail'], + }, + + // ─── Bedrock AgentCore ────────────────────────────────────────────────────── + 'AWS::BedrockAgentCore::Memory': { + create: ['bedrock-agentcore:CreateMemory'], + read: ['bedrock-agentcore:GetMemory'], + update: ['bedrock-agentcore:UpdateMemory'], + delete: ['bedrock-agentcore:DeleteMemory'], + }, + 'AWS::BedrockAgentCore::Runtime': { + create: ['bedrock-agentcore:CreateRuntime'], + read: ['bedrock-agentcore:GetRuntime'], + update: ['bedrock-agentcore:UpdateRuntime'], + delete: ['bedrock-agentcore:DeleteRuntime'], + }, + + // ─── CloudWatch ───────────────────────────────────────────────────────────── + 'AWS::CloudWatch::Alarm': { + create: ['cloudwatch:PutMetricAlarm', 'cloudwatch:TagResource'], + read: ['cloudwatch:DescribeAlarms', 'cloudwatch:ListTagsForResource'], + update: ['cloudwatch:PutMetricAlarm', 'cloudwatch:TagResource', 'cloudwatch:UntagResource'], + delete: ['cloudwatch:DeleteAlarms'], + }, + 'AWS::CloudWatch::Dashboard': { + create: ['cloudwatch:PutDashboard'], + read: ['cloudwatch:GetDashboard'], + update: ['cloudwatch:PutDashboard'], + delete: ['cloudwatch:DeleteDashboards'], + }, + + // ─── Cognito ──────────────────────────────────────────────────────────────── + 'AWS::Cognito::UserPool': { + create: ['cognito-idp:CreateUserPool', 'cognito-idp:TagResource'], + read: ['cognito-idp:DescribeUserPool', 'cognito-idp:ListTagsForResource', 'cognito-idp:GetUserPoolMfaConfig'], + update: ['cognito-idp:UpdateUserPool', 'cognito-idp:TagResource', 'cognito-idp:UntagResource'], + delete: ['cognito-idp:DeleteUserPool'], + }, + 'AWS::Cognito::UserPoolClient': { + create: ['cognito-idp:CreateUserPoolClient'], + read: ['cognito-idp:DescribeUserPoolClient'], + update: ['cognito-idp:UpdateUserPoolClient'], + delete: ['cognito-idp:DeleteUserPoolClient'], + }, + + // ─── DynamoDB ─────────────────────────────────────────────────────────────── + 'AWS::DynamoDB::Table': { + create: ['dynamodb:CreateTable', 'dynamodb:TagResource', 'dynamodb:DescribeTable', 'dynamodb:UpdateTimeToLive', 'dynamodb:UpdateContinuousBackups'], + read: ['dynamodb:DescribeTable', 'dynamodb:DescribeTimeToLive', 'dynamodb:DescribeContinuousBackups', 'dynamodb:ListTagsOfResource', 'dynamodb:DescribeContributorInsights', 'dynamodb:DescribeKinesisStreamingDestination', 'dynamodb:GetResourcePolicy'], + update: ['dynamodb:UpdateTable', 'dynamodb:TagResource', 'dynamodb:UntagResource', 'dynamodb:UpdateTimeToLive', 'dynamodb:UpdateContinuousBackups'], + delete: ['dynamodb:DeleteTable'], + }, + + // ─── EC2 ──────────────────────────────────────────────────────────────────── + 'AWS::EC2::EIP': { + create: ['ec2:AllocateAddress', 'ec2:CreateTags'], + read: ['ec2:DescribeAddresses'], + update: ['ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:ReleaseAddress'], + }, + 'AWS::EC2::FlowLog': { + create: ['ec2:CreateFlowLogs', 'ec2:CreateTags'], + read: ['ec2:DescribeFlowLogs'], + update: ['ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteFlowLogs'], + }, + 'AWS::EC2::InternetGateway': { + create: ['ec2:CreateInternetGateway', 'ec2:CreateTags'], + read: ['ec2:DescribeInternetGateways'], + update: ['ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteInternetGateway'], + }, + 'AWS::EC2::NatGateway': { + create: ['ec2:CreateNatGateway', 'ec2:CreateTags'], + read: ['ec2:DescribeNatGateways'], + update: ['ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteNatGateway'], + }, + 'AWS::EC2::Route': { + create: ['ec2:CreateRoute'], + read: ['ec2:DescribeRouteTables'], + update: ['ec2:CreateRoute', 'ec2:DeleteRoute'], + delete: ['ec2:DeleteRoute'], + }, + 'AWS::EC2::RouteTable': { + create: ['ec2:CreateRouteTable', 'ec2:CreateTags'], + read: ['ec2:DescribeRouteTables'], + update: ['ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteRouteTable'], + }, + 'AWS::EC2::SecurityGroup': { + create: ['ec2:CreateSecurityGroup', 'ec2:CreateTags', 'ec2:AuthorizeSecurityGroupEgress', 'ec2:AuthorizeSecurityGroupIngress'], + read: ['ec2:DescribeSecurityGroups'], + update: ['ec2:AuthorizeSecurityGroupEgress', 'ec2:RevokeSecurityGroupEgress', 'ec2:AuthorizeSecurityGroupIngress', 'ec2:RevokeSecurityGroupIngress', 'ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteSecurityGroup'], + }, + 'AWS::EC2::Subnet': { + create: ['ec2:CreateSubnet', 'ec2:CreateTags', 'ec2:ModifySubnetAttribute'], + read: ['ec2:DescribeSubnets'], + update: ['ec2:ModifySubnetAttribute', 'ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteSubnet'], + }, + 'AWS::EC2::SubnetRouteTableAssociation': { + create: ['ec2:AssociateRouteTable'], + read: ['ec2:DescribeRouteTables'], + update: ['ec2:AssociateRouteTable', 'ec2:DisassociateRouteTable'], + delete: ['ec2:DisassociateRouteTable'], + }, + 'AWS::EC2::VPC': { + create: ['ec2:CreateVpc', 'ec2:CreateTags', 'ec2:ModifyVpcAttribute', 'ec2:DescribeVpcAttribute'], + read: ['ec2:DescribeVpcs', 'ec2:DescribeVpcAttribute'], + update: ['ec2:ModifyVpcAttribute', 'ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteVpc'], + }, + 'AWS::EC2::VPCEndpoint': { + create: ['ec2:CreateVpcEndpoint', 'ec2:CreateTags'], + read: ['ec2:DescribeVpcEndpoints'], + update: ['ec2:ModifyVpcEndpoint', 'ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteVpcEndpoints'], + }, + 'AWS::EC2::VPCGatewayAttachment': { + create: ['ec2:AttachInternetGateway'], + read: ['ec2:DescribeInternetGateways'], + update: ['ec2:AttachInternetGateway', 'ec2:DetachInternetGateway'], + delete: ['ec2:DetachInternetGateway'], + }, + + // ─── Events (EventBridge) ────────────────────────────────────────────────── + 'AWS::Events::Rule': { + create: ['events:PutRule', 'events:PutTargets', 'events:TagResource'], + read: ['events:DescribeRule', 'events:ListTargetsByRule', 'events:ListTagsForResource'], + update: ['events:PutRule', 'events:PutTargets', 'events:RemoveTargets', 'events:TagResource', 'events:UntagResource'], + delete: ['events:DeleteRule', 'events:RemoveTargets'], + }, + + // ─── IAM ──────────────────────────────────────────────────────────────────── + 'AWS::IAM::ManagedPolicy': { + create: ['iam:CreatePolicy', 'iam:TagPolicy'], + read: ['iam:GetPolicy', 'iam:GetPolicyVersion', 'iam:ListPolicyVersions'], + update: ['iam:CreatePolicyVersion', 'iam:DeletePolicyVersion', 'iam:TagPolicy'], + delete: ['iam:DeletePolicy', 'iam:DeletePolicyVersion'], + }, + 'AWS::IAM::Policy': { + create: ['iam:PutRolePolicy'], + read: ['iam:GetRolePolicy'], + update: ['iam:PutRolePolicy'], + delete: ['iam:DeleteRolePolicy'], + }, + 'AWS::IAM::Role': { + create: ['iam:CreateRole', 'iam:TagRole', 'iam:AttachRolePolicy', 'iam:PutRolePolicy', 'iam:PassRole'], + read: ['iam:GetRole', 'iam:ListRoleTags', 'iam:ListRolePolicies', 'iam:ListAttachedRolePolicies', 'iam:GetRolePolicy', 'iam:ListInstanceProfilesForRole'], + update: ['iam:UpdateRole', 'iam:TagRole', 'iam:UntagRole', 'iam:AttachRolePolicy', 'iam:DetachRolePolicy', 'iam:PutRolePolicy', 'iam:DeleteRolePolicy'], + delete: ['iam:DeleteRole', 'iam:DetachRolePolicy', 'iam:DeleteRolePolicy'], + }, + + // ─── Lambda ───────────────────────────────────────────────────────────────── + 'AWS::Lambda::Alias': { + create: ['lambda:CreateAlias'], + read: ['lambda:GetAlias'], + update: ['lambda:UpdateAlias'], + delete: ['lambda:DeleteAlias'], + }, + 'AWS::Lambda::EventInvokeConfig': { + create: ['lambda:PutFunctionEventInvokeConfig'], + read: ['lambda:GetFunctionEventInvokeConfig'], + update: ['lambda:PutFunctionEventInvokeConfig'], + delete: ['lambda:DeleteFunctionEventInvokeConfig'], + }, + 'AWS::Lambda::EventSourceMapping': { + create: ['lambda:CreateEventSourceMapping'], + read: ['lambda:GetEventSourceMapping'], + update: ['lambda:UpdateEventSourceMapping'], + delete: ['lambda:DeleteEventSourceMapping'], + }, + 'AWS::Lambda::Function': { + create: ['lambda:CreateFunction', 'lambda:TagResource'], + read: ['lambda:GetFunction', 'lambda:GetFunctionConfiguration', 'lambda:GetPolicy', 'lambda:ListTags', 'lambda:GetFunctionCodeSigningConfig', 'lambda:GetFunctionRecursionConfig', 'lambda:GetRuntimeManagementConfig'], + update: ['lambda:UpdateFunctionCode', 'lambda:UpdateFunctionConfiguration', 'lambda:TagResource', 'lambda:UntagResource', 'lambda:PutFunctionConcurrency', 'lambda:DeleteFunctionConcurrency'], + delete: ['lambda:DeleteFunction'], + }, + 'AWS::Lambda::LayerVersion': { + create: ['lambda:PublishLayerVersion'], + read: ['lambda:GetLayerVersion'], + update: ['lambda:PublishLayerVersion'], + delete: ['lambda:DeleteLayerVersion'], + }, + 'AWS::Lambda::Permission': { + create: ['lambda:AddPermission'], + read: ['lambda:GetPolicy'], + update: ['lambda:AddPermission', 'lambda:RemovePermission'], + delete: ['lambda:RemovePermission'], + }, + 'AWS::Lambda::Version': { + create: ['lambda:PublishVersion'], + read: ['lambda:GetFunction', 'lambda:GetProvisionedConcurrencyConfig'], + update: ['lambda:PublishVersion'], + delete: ['lambda:DeleteFunction'], + }, + + // ─── Logs (CloudWatch Logs) ──────────────────────────────────────────────── + 'AWS::Logs::Delivery': { + create: ['logs:CreateDelivery'], + read: ['logs:GetDelivery', 'logs:DescribeDeliveries'], + update: ['logs:CreateDelivery', 'logs:DeleteDelivery'], + delete: ['logs:DeleteDelivery'], + }, + 'AWS::Logs::DeliveryDestination': { + create: ['logs:PutDeliveryDestination'], + read: ['logs:GetDeliveryDestination', 'logs:GetDeliveryDestinationPolicy'], + update: ['logs:PutDeliveryDestination'], + delete: ['logs:DeleteDeliveryDestination'], + }, + 'AWS::Logs::DeliverySource': { + create: ['logs:PutDeliverySource'], + read: ['logs:GetDeliverySource'], + update: ['logs:PutDeliverySource'], + delete: ['logs:DeleteDeliverySource'], + }, + 'AWS::Logs::LogGroup': { + create: ['logs:CreateLogGroup', 'logs:TagResource', 'logs:PutRetentionPolicy'], + read: ['logs:DescribeLogGroups', 'logs:ListTagsForResource', 'logs:ListTagsLogGroup'], + update: ['logs:PutRetentionPolicy', 'logs:DeleteRetentionPolicy', 'logs:TagResource', 'logs:UntagResource'], + delete: ['logs:DeleteLogGroup'], + }, + 'AWS::Logs::ResourcePolicy': { + create: ['logs:PutResourcePolicy'], + read: ['logs:DescribeResourcePolicies'], + update: ['logs:PutResourcePolicy'], + delete: ['logs:DeleteResourcePolicy'], + }, + + // ─── Route53 Resolver ────────────────────────────────────────────────────── + 'AWS::Route53Resolver::FirewallDomainList': { + create: ['route53resolver:CreateFirewallDomainList', 'route53resolver:TagResource'], + read: ['route53resolver:GetFirewallDomainList', 'route53resolver:ListTagsForResource'], + update: ['route53resolver:UpdateFirewallDomains', 'route53resolver:TagResource', 'route53resolver:UntagResource'], + delete: ['route53resolver:DeleteFirewallDomainList'], + }, + 'AWS::Route53Resolver::FirewallRuleGroup': { + create: ['route53resolver:CreateFirewallRuleGroup', 'route53resolver:CreateFirewallRule', 'route53resolver:TagResource'], + read: ['route53resolver:GetFirewallRuleGroup', 'route53resolver:ListFirewallRules', 'route53resolver:ListTagsForResource'], + update: ['route53resolver:UpdateFirewallRule', 'route53resolver:CreateFirewallRule', 'route53resolver:DeleteFirewallRule', 'route53resolver:TagResource', 'route53resolver:UntagResource'], + delete: ['route53resolver:DeleteFirewallRuleGroup', 'route53resolver:DeleteFirewallRule'], + }, + 'AWS::Route53Resolver::FirewallRuleGroupAssociation': { + create: ['route53resolver:AssociateFirewallRuleGroup', 'route53resolver:TagResource'], + read: ['route53resolver:GetFirewallRuleGroupAssociation', 'route53resolver:ListFirewallRuleGroupAssociations', 'route53resolver:ListTagsForResource'], + update: ['route53resolver:TagResource', 'route53resolver:UntagResource'], + delete: ['route53resolver:DisassociateFirewallRuleGroup'], + }, + 'AWS::Route53Resolver::ResolverQueryLoggingConfig': { + create: ['route53resolver:CreateResolverQueryLogConfig', 'route53resolver:TagResource'], + read: ['route53resolver:GetResolverQueryLogConfig', 'route53resolver:ListResolverQueryLogConfigs', 'route53resolver:ListTagsForResource'], + update: ['route53resolver:TagResource', 'route53resolver:UntagResource'], + delete: ['route53resolver:DeleteResolverQueryLogConfig'], + }, + 'AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation': { + create: ['route53resolver:AssociateResolverQueryLogConfig'], + read: ['route53resolver:GetResolverQueryLogConfigAssociation', 'route53resolver:ListResolverQueryLogConfigAssociations'], + update: ['route53resolver:AssociateResolverQueryLogConfig', 'route53resolver:DisassociateResolverQueryLogConfig'], + delete: ['route53resolver:DisassociateResolverQueryLogConfig'], + }, + + // ─── S3 ───────────────────────────────────────────────────────────────────── + 'AWS::S3::Bucket': { + create: ['s3:CreateBucket', 's3:PutBucketPolicy', 's3:PutBucketPublicAccessBlock', 's3:PutEncryptionConfiguration', 's3:PutBucketVersioning', 's3:PutBucketTagging'], + read: ['s3:GetBucketPolicy', 's3:GetBucketTagging', 's3:GetEncryptionConfiguration', 's3:GetBucketVersioning', 's3:GetBucketPublicAccessBlock', 's3:GetBucketLocation', 's3:ListBucket'], + update: ['s3:PutBucketPolicy', 's3:PutBucketPublicAccessBlock', 's3:PutEncryptionConfiguration', 's3:PutBucketVersioning', 's3:PutBucketTagging', 's3:DeleteBucketPolicy'], + delete: ['s3:DeleteBucket', 's3:DeleteBucketPolicy'], + }, + 'AWS::S3::BucketPolicy': { + create: ['s3:PutBucketPolicy'], + read: ['s3:GetBucketPolicy'], + update: ['s3:PutBucketPolicy'], + delete: ['s3:DeleteBucketPolicy'], + }, + + // ─── SQS ──────────────────────────────────────────────────────────────────── + 'AWS::SQS::Queue': { + create: ['sqs:CreateQueue', 'sqs:TagQueue', 'sqs:GetQueueUrl', 'sqs:GetQueueAttributes'], + read: ['sqs:GetQueueAttributes', 'sqs:GetQueueUrl'], + update: ['sqs:SetQueueAttributes', 'sqs:TagQueue', 'sqs:UntagQueue'], + delete: ['sqs:DeleteQueue', 'sqs:GetQueueUrl'], + }, + 'AWS::SQS::QueuePolicy': { + create: ['sqs:AddPermission', 'sqs:SetQueueAttributes', 'sqs:GetQueueUrl'], + read: ['sqs:GetQueueAttributes', 'sqs:GetQueueUrl'], + update: ['sqs:SetQueueAttributes', 'sqs:RemovePermission', 'sqs:AddPermission'], + delete: ['sqs:RemovePermission', 'sqs:SetQueueAttributes', 'sqs:GetQueueUrl'], + }, + + // ─── Secrets Manager ─────────────────────────────────────────────────────── + 'AWS::SecretsManager::Secret': { + create: ['secretsmanager:CreateSecret', 'secretsmanager:TagResource', 'secretsmanager:GetRandomPassword'], + read: ['secretsmanager:DescribeSecret', 'secretsmanager:GetSecretValue', 'secretsmanager:GetResourcePolicy'], + update: ['secretsmanager:UpdateSecret', 'secretsmanager:PutSecretValue', 'secretsmanager:TagResource', 'secretsmanager:UntagResource', 'secretsmanager:PutResourcePolicy', 'secretsmanager:DeleteResourcePolicy'], + delete: ['secretsmanager:DeleteSecret'], + }, + + // ─── WAFv2 ────────────────────────────────────────────────────────────────── + 'AWS::WAFv2::WebACL': { + create: ['wafv2:CreateWebACL', 'wafv2:TagResource'], + read: ['wafv2:GetWebACL', 'wafv2:ListTagsForResource'], + update: ['wafv2:UpdateWebACL', 'wafv2:TagResource', 'wafv2:UntagResource'], + delete: ['wafv2:DeleteWebACL'], + }, + 'AWS::WAFv2::WebACLAssociation': { + create: ['wafv2:AssociateWebACL'], + read: ['wafv2:GetWebACLForResource'], + update: ['wafv2:AssociateWebACL', 'wafv2:DisassociateWebACL'], + delete: ['wafv2:DisassociateWebACL'], + }, +}; + +/** + * Returns the ResourceActions entry for a given CloudFormation resource type, + * or undefined if the type is not mapped. + */ +export function getActionsForResource(cfnType: string): ResourceActions | undefined { + return RESOURCE_ACTION_MAP[cfnType]; +} + +/** + * Returns the set of all unique IAM actions referenced across all map entries. + */ +export function getAllMappedActions(): Set { + const actions = new Set(); + for (const entry of Object.values(RESOURCE_ACTION_MAP)) { + for (const action of [...entry.create, ...entry.read, ...entry.update, ...entry.delete]) { + actions.add(action); + } + } + return actions; +} diff --git a/cdk/test/bootstrap/resource-action-map.test.ts b/cdk/test/bootstrap/resource-action-map.test.ts new file mode 100644 index 000000000..4c0c7adca --- /dev/null +++ b/cdk/test/bootstrap/resource-action-map.test.ts @@ -0,0 +1,186 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Stack } from 'aws-cdk-lib'; + +import { allPolicies } from '../../src/bootstrap/policies'; +import { + RESOURCE_ACTION_MAP, + getActionsForResource, + getAllMappedActions, +} from '../../src/bootstrap/preflight'; + +/** + * Extracts all actions from the combined bootstrap policies. + * Returns a set of individual actions PLUS any wildcard prefixes + * (e.g. 'bedrock-agentcore:*' → prefix 'bedrock-agentcore:'). + */ +function extractPolicyActions(): { actions: Set; wildcardPrefixes: Set } { + const actions = new Set(); + const wildcardPrefixes = new Set(); + const stack = new Stack(); + + for (const policyDoc of allPolicies()) { + // Resolve the policy document to get the raw JSON + const resolved = stack.resolve(policyDoc.toJSON()); + for (const statement of resolved.Statement ?? []) { + if (statement.Effect !== 'Allow') continue; + const stmtActions = Array.isArray(statement.Action) + ? statement.Action + : [statement.Action]; + for (const action of stmtActions) { + if (action.endsWith(':*')) { + // Wildcard: extract the service prefix (e.g. 'bedrock-agentcore:*' → 'bedrock-agentcore:') + wildcardPrefixes.add(action.slice(0, action.lastIndexOf('*'))); + } + actions.add(action); + } + } + } + return { actions, wildcardPrefixes }; +} + +/** + * Services with known policy gaps. Actions from these services are excluded + * from the policy coverage assertion, to be addressed in follow-up policy updates. + */ +const KNOWN_GAP_SERVICES = new Set([ + 'sqs', // SQS actions not yet in bootstrap policies + 's3', // S3 bucket lifecycle actions (CreateBucket, etc.) beyond CDK asset access +]); + +/** + * Individual actions not yet in policies but required for specific resource types. + * These are known gaps to be addressed in follow-up policy updates. + */ +const KNOWN_GAP_ACTIONS = new Set([ + // Lambda EventSourceMapping actions not in current policies + 'lambda:CreateEventSourceMapping', + 'lambda:GetEventSourceMapping', + 'lambda:UpdateEventSourceMapping', + 'lambda:DeleteEventSourceMapping', + // Lambda LayerVersion actions not in current policies + 'lambda:PublishLayerVersion', + 'lambda:GetLayerVersion', + 'lambda:DeleteLayerVersion', +]); + +describe('resource-action-map', () => { + describe('map structure', () => { + it('has entries for at least 55 resource types', () => { + const entryCount = Object.keys(RESOURCE_ACTION_MAP).length; + expect(entryCount).toBeGreaterThanOrEqual(55); + }); + + it('every entry has at least one action in create or delete', () => { + for (const [type, entry] of Object.entries(RESOURCE_ACTION_MAP)) { + const hasCreateOrDelete = entry.create.length > 0 || entry.delete.length > 0; + expect(hasCreateOrDelete).toBe(true); + if (!hasCreateOrDelete) { + // Extra info for debugging (won't reach here if assertion passes) + throw new Error(`${type} has no create or delete actions`); + } + } + }); + + it('all actions use valid IAM format (service:ActionName) or wildcard (service:*)', () => { + // Standard format: lowercase-service-with-hyphens : PascalCaseAction + const validFormat = /^[a-z][a-z0-9-]*:[A-Z][A-Za-z0-9]*$/; + // Wildcard format: service:* + const wildcardFormat = /^[a-z][a-z0-9-]*:\*$/; + // API Gateway uses HTTP verbs (uppercase) as actions + const apiGatewayFormat = /^apigateway:(GET|PUT|POST|PATCH|DELETE)$/; + + for (const [type, entry] of Object.entries(RESOURCE_ACTION_MAP)) { + const allActions = [...entry.create, ...entry.read, ...entry.update, ...entry.delete]; + for (const action of allActions) { + const isValid = validFormat.test(action) || wildcardFormat.test(action) || apiGatewayFormat.test(action); + if (!isValid) { + throw new Error(`Invalid action format '${action}' in ${type}`); + } + expect(isValid).toBe(true); + } + } + }); + }); + + describe('policy coverage', () => { + it('all mapped actions (excluding known gaps) exist in the combined policy set', () => { + const { actions: policyActions, wildcardPrefixes } = extractPolicyActions(); + const mappedActions = getAllMappedActions(); + const uncovered: string[] = []; + + for (const action of mappedActions) { + // Skip known-gap services + const service = action.split(':')[0]; + if (KNOWN_GAP_SERVICES.has(service)) continue; + + // Skip known-gap individual actions + if (KNOWN_GAP_ACTIONS.has(action)) continue; + + // Check direct match + if (policyActions.has(action)) continue; + + // Check wildcard coverage (e.g. bedrock-agentcore:* covers bedrock-agentcore:CreateMemory) + const actionPrefix = service + ':'; + if (wildcardPrefixes.has(actionPrefix)) continue; + + uncovered.push(action); + } + + if (uncovered.length > 0) { + throw new Error( + `${uncovered.length} actions not covered by bootstrap policies:\n ${uncovered.join('\n ')}`, + ); + } + expect(uncovered).toHaveLength(0); + }); + }); + + describe('getActionsForResource', () => { + it('returns actions for a known resource type', () => { + const result = getActionsForResource('AWS::Lambda::Function'); + expect(result).toBeDefined(); + expect(result!.create).toContain('lambda:CreateFunction'); + expect(result!.delete).toContain('lambda:DeleteFunction'); + }); + + it('returns undefined for an unknown resource type', () => { + const result = getActionsForResource('AWS::Nonexistent::Resource'); + expect(result).toBeUndefined(); + }); + }); + + describe('getAllMappedActions', () => { + it('returns a non-empty Set', () => { + const actions = getAllMappedActions(); + expect(actions.size).toBeGreaterThan(0); + }); + + it('contains actions from multiple services', () => { + const actions = getAllMappedActions(); + const services = new Set(); + for (const action of actions) { + services.add(action.split(':')[0]); + } + // Should cover at least 10 distinct services + expect(services.size).toBeGreaterThanOrEqual(10); + }); + }); +}); From 95b154a3542aa8ad22c5cc6712252b64484ac7b0 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Thu, 21 May 2026 08:04:38 +0000 Subject: [PATCH 05/11] test(bootstrap): add dual-config synth-coverage test (agentcore + ecs) Validates that all resource types in the synthesized CloudFormation template have entries in the resource-action-map. Tests agentcore from existing cdk.out and attempts ECS synth gracefully skipping when AWS credentials are unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../bootstrap/resource-action-map.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/cdk/test/bootstrap/resource-action-map.test.ts b/cdk/test/bootstrap/resource-action-map.test.ts index 4c0c7adca..faed603cf 100644 --- a/cdk/test/bootstrap/resource-action-map.test.ts +++ b/cdk/test/bootstrap/resource-action-map.test.ts @@ -17,6 +17,10 @@ * SOFTWARE. */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + import { Stack } from 'aws-cdk-lib'; import { allPolicies } from '../../src/bootstrap/policies'; @@ -184,3 +188,48 @@ describe('resource-action-map', () => { }); }); }); + +describe('Synth coverage', () => { + const SKIP_TYPES = new Set([ + 'AWS::CDK::Metadata', + 'Custom::AWS', + 'Custom::S3AutoDeleteObjects', + 'Custom::VpcRestrictDefaultSG', + ]); + + function getResourceTypes(templatePath: string): string[] { + if (!existsSync(templatePath)) return []; + const template = JSON.parse(readFileSync(templatePath, 'utf-8')); + const resources = template.Resources as Record; + return [...new Set(Object.values(resources).map(r => r.Type))]; + } + + it('all agentcore resource types have map entries', () => { + const templatePath = join(__dirname, '..', '..', 'cdk.out', 'backgroundagent-dev.template.json'); + const types = getResourceTypes(templatePath); + if (types.length === 0) return; + const unmapped = types.filter(t => !SKIP_TYPES.has(t) && !RESOURCE_ACTION_MAP[t]); + expect(unmapped).toEqual([]); + }); + + it('all ecs resource types have map entries', () => { + const ecsOutDir = join(__dirname, '..', '..', 'cdk.out.ecs'); + const ecsTemplatePath = join(ecsOutDir, 'backgroundagent-dev.template.json'); + // Try to synth with ecs config — skip if unavailable (no AWS creds, etc.) + if (!existsSync(ecsTemplatePath)) { + try { + execFileSync('npx', ['cdk', 'synth', '-q', '-c', 'compute_type=ecs', '-o', ecsOutDir], { + cwd: join(__dirname, '..', '..'), + stdio: 'pipe', + timeout: 120000, + }); + } catch { + return; // synth unavailable — skip gracefully + } + } + const types = getResourceTypes(ecsTemplatePath); + if (types.length === 0) return; + const unmapped = types.filter(t => !SKIP_TYPES.has(t) && !RESOURCE_ACTION_MAP[t]); + expect(unmapped).toEqual([]); + }); +}); From 71d06a56d4ba3a5e65fedd256e591b4df110dbe1 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Fri, 22 May 2026 00:08:06 +0000 Subject: [PATCH 06/11] fix(bootstrap): compute-agentcore is a variant choice, not a core policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compute_type drives which compute policy is needed — agentcore and ecs are independent choices, not base+optional. An operator deploying only ECS should not require agentcore permissions. Co-Authored-By: Claude Opus 4.6 (1M context) --- cdk/src/bootstrap/required-policies.ts | 2 +- cdk/test/bootstrap/required-policies.test.ts | 23 ++++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/cdk/src/bootstrap/required-policies.ts b/cdk/src/bootstrap/required-policies.ts index 8d907e829..48055bb1a 100644 --- a/cdk/src/bootstrap/required-policies.ts +++ b/cdk/src/bootstrap/required-policies.ts @@ -21,10 +21,10 @@ const CORE_POLICIES = [ 'infrastructure', 'application', 'observability', - 'compute-agentcore', ] as const; const COMPUTE_VARIANT_POLICIES: Record = { + agentcore: ['compute-agentcore'], ecs: ['compute-ecs'], }; diff --git a/cdk/test/bootstrap/required-policies.test.ts b/cdk/test/bootstrap/required-policies.test.ts index 9ef765f5a..8755e05f9 100644 --- a/cdk/test/bootstrap/required-policies.test.ts +++ b/cdk/test/bootstrap/required-policies.test.ts @@ -20,25 +20,30 @@ import { getRequiredBootstrapPolicies } from '../../src/bootstrap/required-policies'; describe('getRequiredBootstrapPolicies', () => { - it('returns core policies plus compute-agentcore for default', () => { + it('returns core policies plus compute-agentcore for agentcore type', () => { const result = getRequiredBootstrapPolicies('agentcore'); expect(result).toEqual(['infrastructure', 'application', 'observability', 'compute-agentcore']); }); - it('includes compute-ecs when compute type is ecs', () => { + it('returns core policies plus compute-ecs for ecs type', () => { const result = getRequiredBootstrapPolicies('ecs'); - expect(result).toContain('compute-ecs'); - expect(result).toContain('compute-agentcore'); + expect(result).toEqual(['infrastructure', 'application', 'observability', 'compute-ecs']); + expect(result).not.toContain('compute-agentcore'); }); - it('always includes compute-agentcore regardless of type', () => { - const result = getRequiredBootstrapPolicies('ecs'); - expect(result).toContain('compute-agentcore'); + it('compute variants are independent choices', () => { + const agentcore = getRequiredBootstrapPolicies('agentcore'); + const ecs = getRequiredBootstrapPolicies('ecs'); + expect(agentcore).toContain('compute-agentcore'); + expect(agentcore).not.toContain('compute-ecs'); + expect(ecs).toContain('compute-ecs'); + expect(ecs).not.toContain('compute-agentcore'); }); - it('returns core policies for unknown compute type', () => { + it('returns only core policies for unknown compute type', () => { const result = getRequiredBootstrapPolicies('unknown'); - expect(result).toEqual(['infrastructure', 'application', 'observability', 'compute-agentcore']); + expect(result).toEqual(['infrastructure', 'application', 'observability']); expect(result).not.toContain('compute-ecs'); + expect(result).not.toContain('compute-agentcore'); }); }); From 7f9886a257af6fd00f19a8974302aabb564826d8 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:59:36 +0000 Subject: [PATCH 07/11] fix(test): move ECS synth output to tmpdir to prevent parallel test race The resource-action-map test previously synthesized into cdk/cdk.out.ecs/ inside the repo tree. CDK's AgentRuntimeArtifact.fromAsset(repoRoot) fingerprints the entire tree, so when github-tags.test runs in parallel it can stat synth.lock mid-lifecycle and hit ENOENT. Co-Authored-By: Claude Opus 4.6 (1M context) --- .dockerignore | 1 + .../bootstrap/resource-action-map.test.ts | 32 +++++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.dockerignore b/.dockerignore index 32526d373..364b7a210 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ # CDK output (recursive include if not excluded) cdk/cdk.out/ +cdk/cdk.out.*/ cdk/lib/ cdk/node_modules/ diff --git a/cdk/test/bootstrap/resource-action-map.test.ts b/cdk/test/bootstrap/resource-action-map.test.ts index faed603cf..14911c274 100644 --- a/cdk/test/bootstrap/resource-action-map.test.ts +++ b/cdk/test/bootstrap/resource-action-map.test.ts @@ -18,7 +18,8 @@ */ import { execFileSync } from 'node:child_process'; -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Stack } from 'aws-cdk-lib'; @@ -213,21 +214,24 @@ describe('Synth coverage', () => { }); it('all ecs resource types have map entries', () => { - const ecsOutDir = join(__dirname, '..', '..', 'cdk.out.ecs'); - const ecsTemplatePath = join(ecsOutDir, 'backgroundagent-dev.template.json'); - // Try to synth with ecs config — skip if unavailable (no AWS creds, etc.) - if (!existsSync(ecsTemplatePath)) { - try { - execFileSync('npx', ['cdk', 'synth', '-q', '-c', 'compute_type=ecs', '-o', ecsOutDir], { - cwd: join(__dirname, '..', '..'), - stdio: 'pipe', - timeout: 120000, - }); - } catch { - return; // synth unavailable — skip gracefully - } + // Use a temp dir outside the repo tree to avoid racing with parallel + // tests that fingerprint repoRoot (e.g. github-tags.test.ts via + // AgentRuntimeArtifact.fromAsset). A synth.lock inside the repo tree + // causes ENOENT when another worker stats it mid-lifecycle. + const ecsOutDir = mkdtempSync(join(tmpdir(), 'cdk-ecs-synth-')); + try { + execFileSync('npx', ['cdk', 'synth', '-q', '-c', 'compute_type=ecs', '-o', ecsOutDir], { + cwd: join(__dirname, '..', '..'), + stdio: 'pipe', + timeout: 120000, + }); + } catch { + rmSync(ecsOutDir, { recursive: true, force: true }); + return; // synth unavailable — skip gracefully } + const ecsTemplatePath = join(ecsOutDir, 'backgroundagent-dev.template.json'); const types = getResourceTypes(ecsTemplatePath); + rmSync(ecsOutDir, { recursive: true, force: true }); if (types.length === 0) return; const unmapped = types.filter(t => !SKIP_TYPES.has(t) && !RESOURCE_ACTION_MAP[t]); expect(unmapped).toEqual([]); From a4ea01a31401bb60495b7f9d5955b52de7f61a3d Mon Sep 17 00:00:00 2001 From: scottschreckengaust <345885+scottschreckengaust@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:53:07 +0000 Subject: [PATCH 08/11] feat(bootstrap): close B1/B2 on the live map + wire compute-type-aware selection (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses krokoko's blocking review on #165. The work lands on main's LIVE map (cdk/src/bootstrap/resource-action-map.ts, consumed by synth-coverage.test.ts) rather than the branch's parallel bootstrap/preflight/ copy — main grew its own map via #351 while this PR sat, and fixing the unreachable one would leave the real gate blind. B1 — ECS was a total validation blind spot. Neither map had any AWS::ECS::* entry, so compute-ecs.ts's 14 `ecs:*` grants were unverified. Confirmed against a real gated synth: `--context compute_type=ecs` emits exactly AWS::ECS::Cluster + AWS::ECS::TaskDefinition as unmapped. Both added, actions derived from compute-ecs.ts. B2 — the dual-config coverage check was vacuous. The original shelled out to `npx cdk synth` and swallowed every failure (`catch { return }`), plus bailed on `types.length === 0` — it burned ~86s, reported green, and asserted nothing. Replaced with an IN-PROCESS ECS-gated synth in synth-coverage.test.ts (no child process, so no try/catch to swallow), and an explicit toContain guard on the two ECS types so the check cannot pass vacuously if the gate ever stops provisioning. Mutation-tested both directions: removing the ECS map entries fails with the 2 unmapped types; hard-coding computeType to 'agentcore' (a silently broken gate) fails the toContain guard. The pre-fix version passed under both. Compute-type-aware selection — RFC #120's sufficiency model is `deployed PolicySet ⊇ the app's required set`, but collectBootstrapAllowActions called allPolicies() unconditionally, validating against the UNION of all five. An agentcore-only operator never deploys compute-ecs, so the union silently accepts `ecs:*` their real IaCRole cannot perform — the over-permissive direction. Added policiesForComputeType(), routed through the salvaged getRequiredBootstrapPolicies so selection cannot drift from the generated artifacts (fails loud on an unregistered name), and made the computeType argument OPTIONAL so the historical union behaviour is preserved for callers that want "grantable by some configuration". Verified scoping: union 357 actions (14 ecs:*), agentcore scope 343 (0 ecs:*), ecs scope 356 (14 ecs:*, 0 bedrock-agentcore:*). 178 suites / 3533 tests pass. B3 needs no work: #596 already landed the ECS-gate tests krokoko asked for (agent.test.ts:674 — cluster + both task-defs, ComputeSubstrate output, and the default no-gate case). Co-Authored-By: Claude --- cdk/src/bootstrap/policies/index.ts | 40 +++++++++++++++++ cdk/src/bootstrap/resource-action-map.ts | 21 +++++++-- cdk/test/bootstrap/required-policies.test.ts | 47 ++++++++++++++++++++ cdk/test/bootstrap/synth-coverage.test.ts | 42 +++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/cdk/src/bootstrap/policies/index.ts b/cdk/src/bootstrap/policies/index.ts index ef89b5bb8..a642c4302 100644 --- a/cdk/src/bootstrap/policies/index.ts +++ b/cdk/src/bootstrap/policies/index.ts @@ -24,6 +24,7 @@ import { computeAgentcorePolicy } from './compute-agentcore'; import { computeEcsPolicy } from './compute-ecs'; import { infrastructurePolicy } from './infrastructure'; import { observabilityPolicy } from './observability'; +import { getRequiredBootstrapPolicies } from '../required-policies'; export { applicationPolicy } from './application'; export { computeAgentcorePolicy } from './compute-agentcore'; @@ -33,6 +34,12 @@ export { observabilityPolicy } from './observability'; /** * Returns all bootstrap IAM PolicyDocuments as an array. + * + * This is the UNION of every variant. Validating an app against it answers + * "could some bootstrap configuration allow this?", not "does THIS deploy's + * bootstrap allow it" — so prefer {@link policiesForComputeType} when the + * compute substrate is known. See RFC #120's sufficiency model + * (`deployed PolicySet ⊇ the app's required set`). */ export function allPolicies(): iam.PolicyDocument[] { return [ @@ -43,3 +50,36 @@ export function allPolicies(): iam.PolicyDocument[] { computeEcsPolicy(), ]; } + +/** Policy documents keyed by the artifact name emitted under `cdk/bootstrap/policies/`. */ +const POLICY_BY_NAME: Record iam.PolicyDocument> = { + 'infrastructure': infrastructurePolicy, + 'application': applicationPolicy, + 'observability': observabilityPolicy, + 'compute-agentcore': computeAgentcorePolicy, + 'compute-ecs': computeEcsPolicy, +}; + +/** + * The PolicyDocuments an operator actually deploys for ``computeType``. + * + * An agentcore-only operator never deploys `compute-ecs`, so validating their + * app against {@link allPolicies} silently accepts `ecs:*` actions their real + * IaCRole cannot perform — the over-permissive direction this map exists to + * catch. Resolves names through {@link getRequiredBootstrapPolicies} so the + * selection and the generated artifacts cannot drift. + */ +export function policiesForComputeType(computeType: string): iam.PolicyDocument[] { + return getRequiredBootstrapPolicies(computeType).map((name) => { + const factory = POLICY_BY_NAME[name]; + if (!factory) { + // Fail loud: a name with no document means the selection list and this + // registry have drifted, which would silently under-scope validation. + throw new Error( + `No bootstrap policy document registered for '${name}'. ` + + `Known: ${Object.keys(POLICY_BY_NAME).join(', ')}.`, + ); + } + return factory(); + }); +} diff --git a/cdk/src/bootstrap/resource-action-map.ts b/cdk/src/bootstrap/resource-action-map.ts index f27394961..31d19c447 100644 --- a/cdk/src/bootstrap/resource-action-map.ts +++ b/cdk/src/bootstrap/resource-action-map.ts @@ -19,7 +19,7 @@ import { aws_iam as iam } from 'aws-cdk-lib'; -import { allPolicies } from './policies'; +import { allPolicies, policiesForComputeType } from './policies'; /** * CloudFormation resource types that do not require IAM actions on the @@ -74,6 +74,11 @@ export const RESOURCE_ACTION_MAP: Record = { 'AWS::EC2::Subnet': ['ec2:CreateSubnet'], 'AWS::EC2::VPC': ['ec2:CreateVpc'], 'AWS::EC2::VPCEndpoint': ['ec2:CreateVpcEndpoint'], + // Only synthesized under `--context compute_type=ecs` (EcsAgentCluster). + // Their absence is what let compute-ecs.ts grant 14 unverified `ecs:*` + // actions — see the ecs synth-coverage test, which now fails loudly (#124). + 'AWS::ECS::Cluster': ['ecs:CreateCluster', 'ecs:TagResource'], + 'AWS::ECS::TaskDefinition': ['ecs:RegisterTaskDefinition', 'ecs:TagResource'], 'AWS::Events::Rule': ['events:PutRule'], 'AWS::IAM::Policy': ['iam:CreatePolicy', 'iam:PutRolePolicy'], 'AWS::IAM::Role': ['iam:CreateRole'], @@ -117,10 +122,20 @@ export function actionIsAllowed(requiredAction: string, allowedAction: string): /** * Collects all Allow actions declared across bootstrap managed policies. + * + * Pass ``computeType`` to scope the set to the policies an operator on that + * substrate actually deploys (RFC #120's `deployed ⊇ required` model). Omitting + * it keeps the historical UNION behaviour, which is right for "is this action + * grantable by SOME configuration?" but too permissive for validating a + * specific deploy: an agentcore-only operator never installs `compute-ecs`, so + * the union silently accepts `ecs:*` their real IaCRole cannot perform. */ -export function collectBootstrapAllowActions(): Set { +export function collectBootstrapAllowActions(computeType?: string): Set { const actions = new Set(); - for (const policy of allPolicies()) { + const policies = computeType === undefined + ? allPolicies() + : policiesForComputeType(computeType); + for (const policy of policies) { const json = policy.toJSON(); for (const stmt of json.Statement ?? []) { if (stmt.Effect !== 'Allow') { diff --git a/cdk/test/bootstrap/required-policies.test.ts b/cdk/test/bootstrap/required-policies.test.ts index 8755e05f9..b27bd0bd8 100644 --- a/cdk/test/bootstrap/required-policies.test.ts +++ b/cdk/test/bootstrap/required-policies.test.ts @@ -17,7 +17,9 @@ * SOFTWARE. */ +import { policiesForComputeType } from '../../src/bootstrap/policies'; import { getRequiredBootstrapPolicies } from '../../src/bootstrap/required-policies'; +import { collectBootstrapAllowActions } from '../../src/bootstrap/resource-action-map'; describe('getRequiredBootstrapPolicies', () => { it('returns core policies plus compute-agentcore for agentcore type', () => { @@ -46,4 +48,49 @@ describe('getRequiredBootstrapPolicies', () => { expect(result).not.toContain('compute-ecs'); expect(result).not.toContain('compute-agentcore'); }); + + it('every selected name resolves to a real policy document', () => { + // Guards the drift this indirection exists to prevent: a name here with no + // document in policies/index.ts would silently under-scope validation. + for (const computeType of ['agentcore', 'ecs']) { + expect(() => policiesForComputeType(computeType)).not.toThrow(); + expect(policiesForComputeType(computeType)).toHaveLength( + getRequiredBootstrapPolicies(computeType).length, + ); + } + }); +}); + +describe('collectBootstrapAllowActions scoping (RFC #120 `deployed ⊇ required`)', () => { + it('excludes ecs:* for an agentcore-only operator', () => { + // The defect this closes: validating against the UNION accepts actions the + // operator's real IaCRole cannot perform, because they never deployed + // compute-ecs. That is the over-permissive direction the map exists to catch. + const agentcore = collectBootstrapAllowActions('agentcore'); + expect([...agentcore].filter((a) => a.startsWith('ecs:'))).toEqual([]); + }); + + it('includes ecs:* only under the ecs substrate, and drops agentcore-only grants', () => { + const ecs = collectBootstrapAllowActions('ecs'); + expect([...ecs].filter((a) => a.startsWith('ecs:')).length).toBeGreaterThan(0); + expect([...ecs].filter((a) => a.startsWith('bedrock-agentcore:'))).toEqual([]); + }); + + it('each scoped set is a strict subset of the union', () => { + const union = collectBootstrapAllowActions(); + for (const computeType of ['agentcore', 'ecs']) { + const scoped = collectBootstrapAllowActions(computeType); + for (const action of scoped) { + expect(union).toContain(action); + } + expect(scoped.size).toBeLessThan(union.size); + } + }); + + it('omitting the compute type preserves the historical union', () => { + const union = collectBootstrapAllowActions(); + expect([...union].filter((a) => a.startsWith('ecs:')).length).toBeGreaterThan(0); + expect([...union].filter((a) => a.startsWith('bedrock-agentcore:')).length) + .toBeGreaterThan(0); + }); }); diff --git a/cdk/test/bootstrap/synth-coverage.test.ts b/cdk/test/bootstrap/synth-coverage.test.ts index 636494aab..9a12832b4 100644 --- a/cdk/test/bootstrap/synth-coverage.test.ts +++ b/cdk/test/bootstrap/synth-coverage.test.ts @@ -72,6 +72,48 @@ describe('Bootstrap policy synth coverage', () => { expect(missingByType).toEqual({}); }); + it('maps every CFN type the ECS substrate adds (--context compute_type=ecs)', () => { + // The ECS gate is the ONLY path that synthesizes AWS::ECS::*, so the default + // synth above cannot see it — compute-ecs.ts granted 14 `ecs:*` actions that + // nothing verified. Synthesized IN-PROCESS with the gate on, deliberately + // NOT shelled out to `npx cdk synth`: a child process needs a try/catch, and + // swallowing a nonzero exit is what made the original version of this check + // pass while asserting nothing (#124 review B2). + const ecsApp = new App({ context: { compute_type: 'ecs' } }); + new AgentStack(ecsApp, 'backgroundagent-dev', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + const ecsTemplate = Template.fromStack( + ecsApp.node.tryFindChild('backgroundagent-dev') as Stack, + ); + const resources = ecsTemplate.toJSON().Resources as Record; + const typesInTemplate = new Set(Object.values(resources).map((r) => r.Type)); + + // Fail loudly if the gate silently stops provisioning ECS: without this the + // rest of the check would pass vacuously the moment the substrate regressed. + expect([...typesInTemplate]).toContain('AWS::ECS::Cluster'); + expect([...typesInTemplate]).toContain('AWS::ECS::TaskDefinition'); + + const unmapped: string[] = []; + const missingByType: Record = {}; + for (const cfnType of typesInTemplate) { + if (CFN_TYPES_WITHOUT_EXEC_ROLE_IAM.has(cfnType)) { + continue; + } + if (!(cfnType in RESOURCE_ACTION_MAP)) { + unmapped.push(cfnType); + continue; + } + const missing = findMissingBootstrapActions(cfnType, allowedActions); + if (missing.length > 0) { + missingByType[cfnType] = missing; + } + } + + expect(unmapped).toEqual([]); + expect(missingByType).toEqual({}); + }); + it('covers integration resources that previously failed deploy (regression)', () => { const regressionTypes = [ 'AWS::SecretsManager::Secret', From f782707ed9eaf8bb310fec8f04e74ffadec28e4c Mon Sep 17 00:00:00 2001 From: scottschreckengaust <345885+scottschreckengaust@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:28:37 +0000 Subject: [PATCH 09/11] feat(bootstrap): grant the 4 actions the map could only exclude (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit krokoko's non-blocking review point: the KNOWN_GAP_SERVICES/KNOWN_GAP_ACTIONS exclusions were "genuine gaps — the stack creates those resources but no policy grants the actions, so the test passes only by excluding the cases it most needs to catch." Closing them by granting, rather than by keeping the exclusion. - s3:GetBucketPolicy, s3:GetEncryptionConfiguration (observability, S3Application Buckets). CloudFormation reads a bucket's policy and encryption config back on stack UPDATE for drift/no-op detection, so the existing Put* grants are insufficient alone. Every other Put* in that statement already had its Get* pair; these two were the omissions. - sqs:AddPermission, sqs:RemovePermission (application, SQS). AWS::SQS::Queue Policy is a distinct CFN resource managed via Add/RemovePermission, NOT SetQueueAttributes. The stack creates one (the DLQ redrive policy), so a queue-policy create/update/delete would fail. Verified all four resolve through actionIsAllowed after the change. BOOTSTRAP_VERSION 1.2.0 -> 1.3.0 (additive grants, backward-compatible) with artifacts regenerated via //cdk:bootstrap:generate, DEPLOYMENT_ROLES.md updated to keep golden-baseline parity, and the Starlight mirror re-synced. NOTE: BOOTSTRAP_HASH is byte-identical after adding four IAM actions, which is wrong — computeBootstrapHash misuses JSON.stringify's replacer argument as a key sort, so it digests `{}` for every statement and is blind to all actions. Pre-existing on main (introduced with the hash in #122), filed as #732 rather than fixed here to keep this PR reviewable. 178 suites / 3533 tests pass. Co-Authored-By: Claude --- cdk/bootstrap/BOOTSTRAP_VERSION | 2 +- cdk/bootstrap/bootstrap-template.yaml | 8 ++++++-- cdk/bootstrap/policies/application.json | 4 +++- cdk/bootstrap/policies/observability.json | 2 ++ cdk/src/bootstrap/policies/application.ts | 7 +++++++ cdk/src/bootstrap/policies/observability.ts | 7 +++++++ cdk/src/bootstrap/version.ts | 2 +- docs/design/DEPLOYMENT_ROLES.md | 6 +++++- docs/src/content/docs/architecture/Deployment-roles.md | 6 +++++- 9 files changed, 37 insertions(+), 7 deletions(-) diff --git a/cdk/bootstrap/BOOTSTRAP_VERSION b/cdk/bootstrap/BOOTSTRAP_VERSION index 26aaba0e8..f0bb29e76 100644 --- a/cdk/bootstrap/BOOTSTRAP_VERSION +++ b/cdk/bootstrap/BOOTSTRAP_VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/cdk/bootstrap/bootstrap-template.yaml b/cdk/bootstrap/bootstrap-template.yaml index 8026647ce..a0a8c5ccf 100644 --- a/cdk/bootstrap/bootstrap-template.yaml +++ b/cdk/bootstrap/bootstrap-template.yaml @@ -1,6 +1,6 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # This template is generated by: npx tsx scripts/generate-bootstrap-template.ts -# ABCA Bootstrap Policy Version: 1.2.0 +# ABCA Bootstrap Policy Version: 1.3.0 # ABCA Bootstrap Policy Hash: b0501c8a57f20e4b5bf50d6fe8bbb934310adc4b64480228565d8392a19d1503 # # Based on the default CDK bootstrap template with the following modifications: @@ -1099,6 +1099,8 @@ Resources: - sqs:UntagQueue - sqs:GetQueueUrl - sqs:ListQueueTags + - sqs:AddPermission + - sqs:RemovePermission Effect: Allow Resource: arn:aws:sqs:*:*:backgroundagent-dev-* Sid: SQS @@ -1228,9 +1230,11 @@ Resources: - s3:DeleteBucket - s3:PutBucketPolicy - s3:DeleteBucketPolicy + - s3:GetBucketPolicy - s3:PutBucketPublicAccessBlock - s3:GetBucketPublicAccessBlock - s3:PutEncryptionConfiguration + - s3:GetEncryptionConfiguration - s3:PutLifecycleConfiguration - s3:PutBucketVersioning - s3:GetBucketVersioning @@ -1370,7 +1374,7 @@ Outputs: Value: '32' BootstrapPolicyVersion: Description: The version of the ABCA bootstrap policy bundle - Value: 1.2.0 + Value: 1.3.0 BootstrapPolicyHash: Description: SHA-256 hash of the ABCA bootstrap policy bundle for drift detection Value: b0501c8a57f20e4b5bf50d6fe8bbb934310adc4b64480228565d8392a19d1503 diff --git a/cdk/bootstrap/policies/application.json b/cdk/bootstrap/policies/application.json index 9f5932d89..e105e946b 100644 --- a/cdk/bootstrap/policies/application.json +++ b/cdk/bootstrap/policies/application.json @@ -165,7 +165,9 @@ "sqs:TagQueue", "sqs:UntagQueue", "sqs:GetQueueUrl", - "sqs:ListQueueTags" + "sqs:ListQueueTags", + "sqs:AddPermission", + "sqs:RemovePermission" ], "Effect": "Allow", "Resource": "arn:aws:sqs:*:*:backgroundagent-dev-*", diff --git a/cdk/bootstrap/policies/observability.json b/cdk/bootstrap/policies/observability.json index 364a452ff..09b01312a 100644 --- a/cdk/bootstrap/policies/observability.json +++ b/cdk/bootstrap/policies/observability.json @@ -87,9 +87,11 @@ "s3:DeleteBucket", "s3:PutBucketPolicy", "s3:DeleteBucketPolicy", + "s3:GetBucketPolicy", "s3:PutBucketPublicAccessBlock", "s3:GetBucketPublicAccessBlock", "s3:PutEncryptionConfiguration", + "s3:GetEncryptionConfiguration", "s3:PutLifecycleConfiguration", "s3:PutBucketVersioning", "s3:GetBucketVersioning", diff --git a/cdk/src/bootstrap/policies/application.ts b/cdk/src/bootstrap/policies/application.ts index 88b668628..12b4f954f 100644 --- a/cdk/src/bootstrap/policies/application.ts +++ b/cdk/src/bootstrap/policies/application.ts @@ -210,6 +210,13 @@ export function applicationPolicy(): iam.PolicyDocument { 'sqs:UntagQueue', 'sqs:GetQueueUrl', 'sqs:ListQueueTags', + // AWS::SQS::QueuePolicy is a distinct CFN resource from the queue, and + // CloudFormation manages it with Add/RemovePermission — not + // SetQueueAttributes. The stack creates one (the DLQ redrive policy), + // so without these a queue-policy create/update/delete fails + // (#124 review — previously excluded via KNOWN_GAP rather than granted). + 'sqs:AddPermission', + 'sqs:RemovePermission', ], resources: ['arn:aws:sqs:*:*:backgroundagent-dev-*'], }), diff --git a/cdk/src/bootstrap/policies/observability.ts b/cdk/src/bootstrap/policies/observability.ts index 70356bd89..aca91c1ea 100644 --- a/cdk/src/bootstrap/policies/observability.ts +++ b/cdk/src/bootstrap/policies/observability.ts @@ -124,9 +124,16 @@ export function observabilityPolicy(): iam.PolicyDocument { 's3:DeleteBucket', 's3:PutBucketPolicy', 's3:DeleteBucketPolicy', + // CloudFormation reads a bucket's policy and encryption config back on + // stack UPDATE (drift/no-op detection), so the Put* grants above are + // insufficient on their own. Every other Put* here has its Get* pair; + // these two were the omissions (#124 review — previously excluded via + // the resource-action-map's KNOWN_GAP set rather than granted). + 's3:GetBucketPolicy', 's3:PutBucketPublicAccessBlock', 's3:GetBucketPublicAccessBlock', 's3:PutEncryptionConfiguration', + 's3:GetEncryptionConfiguration', 's3:PutLifecycleConfiguration', 's3:PutBucketVersioning', 's3:GetBucketVersioning', diff --git a/cdk/src/bootstrap/version.ts b/cdk/src/bootstrap/version.ts index d81d32326..a8e60b44d 100644 --- a/cdk/src/bootstrap/version.ts +++ b/cdk/src/bootstrap/version.ts @@ -22,7 +22,7 @@ import { createHash } from 'node:crypto'; import { allPolicies } from './policies'; /** Semantic version of the bootstrap policy bundle. */ -export const BOOTSTRAP_VERSION = '1.2.0'; +export const BOOTSTRAP_VERSION = '1.3.0'; /** * Computes a SHA-256 hash over all bootstrap policies. diff --git a/docs/design/DEPLOYMENT_ROLES.md b/docs/design/DEPLOYMENT_ROLES.md index 9f2f0baca..075f65dc4 100644 --- a/docs/design/DEPLOYMENT_ROLES.md +++ b/docs/design/DEPLOYMENT_ROLES.md @@ -445,7 +445,9 @@ DynamoDB tables, Lambda functions, API Gateway, Cognito, WAFv2, EventBridge, SQS "sqs:TagQueue", "sqs:UntagQueue", "sqs:GetQueueUrl", - "sqs:ListQueueTags" + "sqs:ListQueueTags", + "sqs:AddPermission", + "sqs:RemovePermission" ], "Resource": "arn:aws:sqs:*:*:backgroundagent-dev-*" }, @@ -610,9 +612,11 @@ Bedrock Guardrails, CloudWatch Logs/Dashboards/Alarms, X-Ray, S3 (CDK assets), K "s3:DeleteBucket", "s3:PutBucketPolicy", "s3:DeleteBucketPolicy", + "s3:GetBucketPolicy", "s3:PutBucketPublicAccessBlock", "s3:GetBucketPublicAccessBlock", "s3:PutEncryptionConfiguration", + "s3:GetEncryptionConfiguration", "s3:PutLifecycleConfiguration", "s3:PutBucketVersioning", "s3:GetBucketVersioning", diff --git a/docs/src/content/docs/architecture/Deployment-roles.md b/docs/src/content/docs/architecture/Deployment-roles.md index a852a9b67..482641b34 100644 --- a/docs/src/content/docs/architecture/Deployment-roles.md +++ b/docs/src/content/docs/architecture/Deployment-roles.md @@ -449,7 +449,9 @@ DynamoDB tables, Lambda functions, API Gateway, Cognito, WAFv2, EventBridge, SQS "sqs:TagQueue", "sqs:UntagQueue", "sqs:GetQueueUrl", - "sqs:ListQueueTags" + "sqs:ListQueueTags", + "sqs:AddPermission", + "sqs:RemovePermission" ], "Resource": "arn:aws:sqs:*:*:backgroundagent-dev-*" }, @@ -614,9 +616,11 @@ Bedrock Guardrails, CloudWatch Logs/Dashboards/Alarms, X-Ray, S3 (CDK assets), K "s3:DeleteBucket", "s3:PutBucketPolicy", "s3:DeleteBucketPolicy", + "s3:GetBucketPolicy", "s3:PutBucketPublicAccessBlock", "s3:GetBucketPublicAccessBlock", "s3:PutEncryptionConfiguration", + "s3:GetEncryptionConfiguration", "s3:PutLifecycleConfiguration", "s3:PutBucketVersioning", "s3:GetBucketVersioning", From 58ad2653e00ad3c10a80230103369a7a1b80a840 Mon Sep 17 00:00:00 2001 From: scottschreckengaust <345885+scottschreckengaust@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:34:17 +0000 Subject: [PATCH 10/11] refactor(bootstrap): one CRUD map on the live path, preflight becomes a facade (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retires the duplicate map. Two copies existed: bootstrap/preflight/ carried CRUD depth with no production consumer, while bootstrap/resource-action-map.ts was create-only and wired into the live synth-coverage gate. Disjoint test suites and no shared consumer means they drift by construction, and adding a resource type to only one of them is silent. Merged programmatically, not by hand, with the invariant asserted mechanically: every action from the create-only map survives in the merged entry's `create` phase (verified 0 lost across 52 types). Result is 64 types / 430 actions, up from 52 create-only entries — the CRUD map contributed 48 create-phase actions the live map lacked on shared types, plus AWS::IAM::ManagedPolicy, while main's 6 extra types (CloudFront, Custom::*, CDK::Metadata) are preserved. - RESOURCE_ACTION_MAP is now Record with create/read/update/delete. findMissingBootstrapActions defaults to ['create'], preserving the pre-CRUD contract for existing callers; pass phases to widen. - bootstrap/preflight/resource-action-map.ts holds NO data — it re-exports the single map and keeps the query helpers (getActionsForResource, getAllMappedActions) that #125/#126 will read it through. 428 lines -> 62. - Deleted the two vacuous 'Synth coverage' tests here: both bailed silently (`catch { return }`, `types.length === 0`) and the ECS one burned ~86s asserting nothing. synth-coverage.test.ts now covers both configs in-process and fails loudly (previous commit). KNOWN_GAP_SERVICES / KNOWN_GAP_ACTIONS removed entirely. Verified every one of the 11 excluded actions is now covered — the sqs/s3 service-wide exclusions and all 7 lambda actions were stale, hiding nothing. With the 4 real gaps granted in the previous commit, the coverage assertion runs over ALL 430 actions in ALL four phases with zero exemptions, which is what krokoko asked for ("the test passes only by excluding the cases it most needs to catch"). Mutation-tested: revoking sqs:AddPermission fails with "1 actions not covered by bootstrap policies: sqs:AddPermission". Added structural pins so the depth cannot erode: every entry must declare all four phases as arrays, and >=45 entries must carry real update/delete actions. 178 suites / 3533 tests pass; //cdk:eslint clean, no mutations. Co-Authored-By: Claude --- cdk/src/bootstrap/preflight/index.ts | 2 +- .../preflight/resource-action-map.ts | 408 +---------- cdk/src/bootstrap/resource-action-map.ts | 671 ++++++++++++++++-- .../bootstrap/resource-action-map.test.ts | 110 +-- 4 files changed, 654 insertions(+), 537 deletions(-) diff --git a/cdk/src/bootstrap/preflight/index.ts b/cdk/src/bootstrap/preflight/index.ts index 7d317acd9..1108f1bd6 100644 --- a/cdk/src/bootstrap/preflight/index.ts +++ b/cdk/src/bootstrap/preflight/index.ts @@ -22,4 +22,4 @@ export { getActionsForResource, getAllMappedActions, } from './resource-action-map'; -export type { ResourceActions } from './resource-action-map'; +export type { ResourceActions, LifecyclePhase } from './resource-action-map'; diff --git a/cdk/src/bootstrap/preflight/resource-action-map.ts b/cdk/src/bootstrap/preflight/resource-action-map.ts index 29a441a57..61a7299fc 100644 --- a/cdk/src/bootstrap/preflight/resource-action-map.ts +++ b/cdk/src/bootstrap/preflight/resource-action-map.ts @@ -18,393 +18,26 @@ */ /** - * Maps CloudFormation resource types to the IAM actions required for each - * lifecycle phase (create, read, update, delete). Actions are sourced from - * CloudTrail-validated policies in docs/design/DEPLOYMENT_ROLES.md. + * Verification helpers over the bootstrap resource-action map. + * + * This module deliberately holds NO map data. It previously carried a second, + * parallel copy: this directory's version had CRUD depth but no production + * consumer, while ``../resource-action-map.ts`` was create-only and wired into + * the live synth-coverage gate. Two maps with disjoint test suites and no shared + * consumer drift by construction, and adding a resource type to only one of them + * is silent. The CRUD depth was merged INTO the live map (#124); what remains + * here are the query helpers the preflight/validation layer (#125/#126) reads it + * through. */ -export interface ResourceActions { - create: string[]; - read: string[]; - update: string[]; - delete: string[]; -} - -export const RESOURCE_ACTION_MAP: Record = { - // ─── API Gateway ──────────────────────────────────────────────────────────── - 'AWS::ApiGateway::Account': { - create: ['apigateway:PATCH'], - read: ['apigateway:GET'], - update: ['apigateway:PATCH'], - delete: ['apigateway:PATCH'], - }, - 'AWS::ApiGateway::Authorizer': { - create: ['apigateway:POST'], - read: ['apigateway:GET'], - update: ['apigateway:PATCH'], - delete: ['apigateway:DELETE'], - }, - 'AWS::ApiGateway::Deployment': { - create: ['apigateway:POST'], - read: ['apigateway:GET'], - update: ['apigateway:PATCH'], - delete: ['apigateway:DELETE'], - }, - 'AWS::ApiGateway::Method': { - create: ['apigateway:PUT'], - read: ['apigateway:GET'], - update: ['apigateway:PUT'], - delete: ['apigateway:DELETE'], - }, - 'AWS::ApiGateway::RequestValidator': { - create: ['apigateway:POST'], - read: ['apigateway:GET'], - update: ['apigateway:PATCH'], - delete: ['apigateway:DELETE'], - }, - 'AWS::ApiGateway::Resource': { - create: ['apigateway:POST'], - read: ['apigateway:GET'], - update: ['apigateway:PATCH'], - delete: ['apigateway:DELETE'], - }, - 'AWS::ApiGateway::RestApi': { - create: ['apigateway:POST', 'apigateway:TagResource'], - read: ['apigateway:GET'], - update: ['apigateway:PATCH', 'apigateway:TagResource', 'apigateway:UntagResource'], - delete: ['apigateway:DELETE'], - }, - 'AWS::ApiGateway::Stage': { - create: ['apigateway:POST', 'apigateway:TagResource'], - read: ['apigateway:GET'], - update: ['apigateway:PATCH', 'apigateway:TagResource', 'apigateway:UntagResource'], - delete: ['apigateway:DELETE'], - }, - - // ─── Bedrock ──────────────────────────────────────────────────────────────── - 'AWS::Bedrock::Guardrail': { - create: ['bedrock:CreateGuardrail', 'bedrock:TagResource'], - read: ['bedrock:GetGuardrail', 'bedrock:ListTagsForResource'], - update: ['bedrock:UpdateGuardrail', 'bedrock:TagResource', 'bedrock:UntagResource'], - delete: ['bedrock:DeleteGuardrail'], - }, - 'AWS::Bedrock::GuardrailVersion': { - create: ['bedrock:CreateGuardrailVersion'], - read: ['bedrock:GetGuardrail'], - update: ['bedrock:CreateGuardrailVersion'], - delete: ['bedrock:DeleteGuardrail'], - }, - - // ─── Bedrock AgentCore ────────────────────────────────────────────────────── - 'AWS::BedrockAgentCore::Memory': { - create: ['bedrock-agentcore:CreateMemory'], - read: ['bedrock-agentcore:GetMemory'], - update: ['bedrock-agentcore:UpdateMemory'], - delete: ['bedrock-agentcore:DeleteMemory'], - }, - 'AWS::BedrockAgentCore::Runtime': { - create: ['bedrock-agentcore:CreateRuntime'], - read: ['bedrock-agentcore:GetRuntime'], - update: ['bedrock-agentcore:UpdateRuntime'], - delete: ['bedrock-agentcore:DeleteRuntime'], - }, - - // ─── CloudWatch ───────────────────────────────────────────────────────────── - 'AWS::CloudWatch::Alarm': { - create: ['cloudwatch:PutMetricAlarm', 'cloudwatch:TagResource'], - read: ['cloudwatch:DescribeAlarms', 'cloudwatch:ListTagsForResource'], - update: ['cloudwatch:PutMetricAlarm', 'cloudwatch:TagResource', 'cloudwatch:UntagResource'], - delete: ['cloudwatch:DeleteAlarms'], - }, - 'AWS::CloudWatch::Dashboard': { - create: ['cloudwatch:PutDashboard'], - read: ['cloudwatch:GetDashboard'], - update: ['cloudwatch:PutDashboard'], - delete: ['cloudwatch:DeleteDashboards'], - }, - - // ─── Cognito ──────────────────────────────────────────────────────────────── - 'AWS::Cognito::UserPool': { - create: ['cognito-idp:CreateUserPool', 'cognito-idp:TagResource'], - read: ['cognito-idp:DescribeUserPool', 'cognito-idp:ListTagsForResource', 'cognito-idp:GetUserPoolMfaConfig'], - update: ['cognito-idp:UpdateUserPool', 'cognito-idp:TagResource', 'cognito-idp:UntagResource'], - delete: ['cognito-idp:DeleteUserPool'], - }, - 'AWS::Cognito::UserPoolClient': { - create: ['cognito-idp:CreateUserPoolClient'], - read: ['cognito-idp:DescribeUserPoolClient'], - update: ['cognito-idp:UpdateUserPoolClient'], - delete: ['cognito-idp:DeleteUserPoolClient'], - }, - - // ─── DynamoDB ─────────────────────────────────────────────────────────────── - 'AWS::DynamoDB::Table': { - create: ['dynamodb:CreateTable', 'dynamodb:TagResource', 'dynamodb:DescribeTable', 'dynamodb:UpdateTimeToLive', 'dynamodb:UpdateContinuousBackups'], - read: ['dynamodb:DescribeTable', 'dynamodb:DescribeTimeToLive', 'dynamodb:DescribeContinuousBackups', 'dynamodb:ListTagsOfResource', 'dynamodb:DescribeContributorInsights', 'dynamodb:DescribeKinesisStreamingDestination', 'dynamodb:GetResourcePolicy'], - update: ['dynamodb:UpdateTable', 'dynamodb:TagResource', 'dynamodb:UntagResource', 'dynamodb:UpdateTimeToLive', 'dynamodb:UpdateContinuousBackups'], - delete: ['dynamodb:DeleteTable'], - }, - - // ─── EC2 ──────────────────────────────────────────────────────────────────── - 'AWS::EC2::EIP': { - create: ['ec2:AllocateAddress', 'ec2:CreateTags'], - read: ['ec2:DescribeAddresses'], - update: ['ec2:CreateTags', 'ec2:DeleteTags'], - delete: ['ec2:ReleaseAddress'], - }, - 'AWS::EC2::FlowLog': { - create: ['ec2:CreateFlowLogs', 'ec2:CreateTags'], - read: ['ec2:DescribeFlowLogs'], - update: ['ec2:CreateTags', 'ec2:DeleteTags'], - delete: ['ec2:DeleteFlowLogs'], - }, - 'AWS::EC2::InternetGateway': { - create: ['ec2:CreateInternetGateway', 'ec2:CreateTags'], - read: ['ec2:DescribeInternetGateways'], - update: ['ec2:CreateTags', 'ec2:DeleteTags'], - delete: ['ec2:DeleteInternetGateway'], - }, - 'AWS::EC2::NatGateway': { - create: ['ec2:CreateNatGateway', 'ec2:CreateTags'], - read: ['ec2:DescribeNatGateways'], - update: ['ec2:CreateTags', 'ec2:DeleteTags'], - delete: ['ec2:DeleteNatGateway'], - }, - 'AWS::EC2::Route': { - create: ['ec2:CreateRoute'], - read: ['ec2:DescribeRouteTables'], - update: ['ec2:CreateRoute', 'ec2:DeleteRoute'], - delete: ['ec2:DeleteRoute'], - }, - 'AWS::EC2::RouteTable': { - create: ['ec2:CreateRouteTable', 'ec2:CreateTags'], - read: ['ec2:DescribeRouteTables'], - update: ['ec2:CreateTags', 'ec2:DeleteTags'], - delete: ['ec2:DeleteRouteTable'], - }, - 'AWS::EC2::SecurityGroup': { - create: ['ec2:CreateSecurityGroup', 'ec2:CreateTags', 'ec2:AuthorizeSecurityGroupEgress', 'ec2:AuthorizeSecurityGroupIngress'], - read: ['ec2:DescribeSecurityGroups'], - update: ['ec2:AuthorizeSecurityGroupEgress', 'ec2:RevokeSecurityGroupEgress', 'ec2:AuthorizeSecurityGroupIngress', 'ec2:RevokeSecurityGroupIngress', 'ec2:CreateTags', 'ec2:DeleteTags'], - delete: ['ec2:DeleteSecurityGroup'], - }, - 'AWS::EC2::Subnet': { - create: ['ec2:CreateSubnet', 'ec2:CreateTags', 'ec2:ModifySubnetAttribute'], - read: ['ec2:DescribeSubnets'], - update: ['ec2:ModifySubnetAttribute', 'ec2:CreateTags', 'ec2:DeleteTags'], - delete: ['ec2:DeleteSubnet'], - }, - 'AWS::EC2::SubnetRouteTableAssociation': { - create: ['ec2:AssociateRouteTable'], - read: ['ec2:DescribeRouteTables'], - update: ['ec2:AssociateRouteTable', 'ec2:DisassociateRouteTable'], - delete: ['ec2:DisassociateRouteTable'], - }, - 'AWS::EC2::VPC': { - create: ['ec2:CreateVpc', 'ec2:CreateTags', 'ec2:ModifyVpcAttribute', 'ec2:DescribeVpcAttribute'], - read: ['ec2:DescribeVpcs', 'ec2:DescribeVpcAttribute'], - update: ['ec2:ModifyVpcAttribute', 'ec2:CreateTags', 'ec2:DeleteTags'], - delete: ['ec2:DeleteVpc'], - }, - 'AWS::EC2::VPCEndpoint': { - create: ['ec2:CreateVpcEndpoint', 'ec2:CreateTags'], - read: ['ec2:DescribeVpcEndpoints'], - update: ['ec2:ModifyVpcEndpoint', 'ec2:CreateTags', 'ec2:DeleteTags'], - delete: ['ec2:DeleteVpcEndpoints'], - }, - 'AWS::EC2::VPCGatewayAttachment': { - create: ['ec2:AttachInternetGateway'], - read: ['ec2:DescribeInternetGateways'], - update: ['ec2:AttachInternetGateway', 'ec2:DetachInternetGateway'], - delete: ['ec2:DetachInternetGateway'], - }, - - // ─── Events (EventBridge) ────────────────────────────────────────────────── - 'AWS::Events::Rule': { - create: ['events:PutRule', 'events:PutTargets', 'events:TagResource'], - read: ['events:DescribeRule', 'events:ListTargetsByRule', 'events:ListTagsForResource'], - update: ['events:PutRule', 'events:PutTargets', 'events:RemoveTargets', 'events:TagResource', 'events:UntagResource'], - delete: ['events:DeleteRule', 'events:RemoveTargets'], - }, - - // ─── IAM ──────────────────────────────────────────────────────────────────── - 'AWS::IAM::ManagedPolicy': { - create: ['iam:CreatePolicy', 'iam:TagPolicy'], - read: ['iam:GetPolicy', 'iam:GetPolicyVersion', 'iam:ListPolicyVersions'], - update: ['iam:CreatePolicyVersion', 'iam:DeletePolicyVersion', 'iam:TagPolicy'], - delete: ['iam:DeletePolicy', 'iam:DeletePolicyVersion'], - }, - 'AWS::IAM::Policy': { - create: ['iam:PutRolePolicy'], - read: ['iam:GetRolePolicy'], - update: ['iam:PutRolePolicy'], - delete: ['iam:DeleteRolePolicy'], - }, - 'AWS::IAM::Role': { - create: ['iam:CreateRole', 'iam:TagRole', 'iam:AttachRolePolicy', 'iam:PutRolePolicy', 'iam:PassRole'], - read: ['iam:GetRole', 'iam:ListRoleTags', 'iam:ListRolePolicies', 'iam:ListAttachedRolePolicies', 'iam:GetRolePolicy', 'iam:ListInstanceProfilesForRole'], - update: ['iam:UpdateRole', 'iam:TagRole', 'iam:UntagRole', 'iam:AttachRolePolicy', 'iam:DetachRolePolicy', 'iam:PutRolePolicy', 'iam:DeleteRolePolicy'], - delete: ['iam:DeleteRole', 'iam:DetachRolePolicy', 'iam:DeleteRolePolicy'], - }, - - // ─── Lambda ───────────────────────────────────────────────────────────────── - 'AWS::Lambda::Alias': { - create: ['lambda:CreateAlias'], - read: ['lambda:GetAlias'], - update: ['lambda:UpdateAlias'], - delete: ['lambda:DeleteAlias'], - }, - 'AWS::Lambda::EventInvokeConfig': { - create: ['lambda:PutFunctionEventInvokeConfig'], - read: ['lambda:GetFunctionEventInvokeConfig'], - update: ['lambda:PutFunctionEventInvokeConfig'], - delete: ['lambda:DeleteFunctionEventInvokeConfig'], - }, - 'AWS::Lambda::EventSourceMapping': { - create: ['lambda:CreateEventSourceMapping'], - read: ['lambda:GetEventSourceMapping'], - update: ['lambda:UpdateEventSourceMapping'], - delete: ['lambda:DeleteEventSourceMapping'], - }, - 'AWS::Lambda::Function': { - create: ['lambda:CreateFunction', 'lambda:TagResource'], - read: ['lambda:GetFunction', 'lambda:GetFunctionConfiguration', 'lambda:GetPolicy', 'lambda:ListTags', 'lambda:GetFunctionCodeSigningConfig', 'lambda:GetFunctionRecursionConfig', 'lambda:GetRuntimeManagementConfig'], - update: ['lambda:UpdateFunctionCode', 'lambda:UpdateFunctionConfiguration', 'lambda:TagResource', 'lambda:UntagResource', 'lambda:PutFunctionConcurrency', 'lambda:DeleteFunctionConcurrency'], - delete: ['lambda:DeleteFunction'], - }, - 'AWS::Lambda::LayerVersion': { - create: ['lambda:PublishLayerVersion'], - read: ['lambda:GetLayerVersion'], - update: ['lambda:PublishLayerVersion'], - delete: ['lambda:DeleteLayerVersion'], - }, - 'AWS::Lambda::Permission': { - create: ['lambda:AddPermission'], - read: ['lambda:GetPolicy'], - update: ['lambda:AddPermission', 'lambda:RemovePermission'], - delete: ['lambda:RemovePermission'], - }, - 'AWS::Lambda::Version': { - create: ['lambda:PublishVersion'], - read: ['lambda:GetFunction', 'lambda:GetProvisionedConcurrencyConfig'], - update: ['lambda:PublishVersion'], - delete: ['lambda:DeleteFunction'], - }, - - // ─── Logs (CloudWatch Logs) ──────────────────────────────────────────────── - 'AWS::Logs::Delivery': { - create: ['logs:CreateDelivery'], - read: ['logs:GetDelivery', 'logs:DescribeDeliveries'], - update: ['logs:CreateDelivery', 'logs:DeleteDelivery'], - delete: ['logs:DeleteDelivery'], - }, - 'AWS::Logs::DeliveryDestination': { - create: ['logs:PutDeliveryDestination'], - read: ['logs:GetDeliveryDestination', 'logs:GetDeliveryDestinationPolicy'], - update: ['logs:PutDeliveryDestination'], - delete: ['logs:DeleteDeliveryDestination'], - }, - 'AWS::Logs::DeliverySource': { - create: ['logs:PutDeliverySource'], - read: ['logs:GetDeliverySource'], - update: ['logs:PutDeliverySource'], - delete: ['logs:DeleteDeliverySource'], - }, - 'AWS::Logs::LogGroup': { - create: ['logs:CreateLogGroup', 'logs:TagResource', 'logs:PutRetentionPolicy'], - read: ['logs:DescribeLogGroups', 'logs:ListTagsForResource', 'logs:ListTagsLogGroup'], - update: ['logs:PutRetentionPolicy', 'logs:DeleteRetentionPolicy', 'logs:TagResource', 'logs:UntagResource'], - delete: ['logs:DeleteLogGroup'], - }, - 'AWS::Logs::ResourcePolicy': { - create: ['logs:PutResourcePolicy'], - read: ['logs:DescribeResourcePolicies'], - update: ['logs:PutResourcePolicy'], - delete: ['logs:DeleteResourcePolicy'], - }, - - // ─── Route53 Resolver ────────────────────────────────────────────────────── - 'AWS::Route53Resolver::FirewallDomainList': { - create: ['route53resolver:CreateFirewallDomainList', 'route53resolver:TagResource'], - read: ['route53resolver:GetFirewallDomainList', 'route53resolver:ListTagsForResource'], - update: ['route53resolver:UpdateFirewallDomains', 'route53resolver:TagResource', 'route53resolver:UntagResource'], - delete: ['route53resolver:DeleteFirewallDomainList'], - }, - 'AWS::Route53Resolver::FirewallRuleGroup': { - create: ['route53resolver:CreateFirewallRuleGroup', 'route53resolver:CreateFirewallRule', 'route53resolver:TagResource'], - read: ['route53resolver:GetFirewallRuleGroup', 'route53resolver:ListFirewallRules', 'route53resolver:ListTagsForResource'], - update: ['route53resolver:UpdateFirewallRule', 'route53resolver:CreateFirewallRule', 'route53resolver:DeleteFirewallRule', 'route53resolver:TagResource', 'route53resolver:UntagResource'], - delete: ['route53resolver:DeleteFirewallRuleGroup', 'route53resolver:DeleteFirewallRule'], - }, - 'AWS::Route53Resolver::FirewallRuleGroupAssociation': { - create: ['route53resolver:AssociateFirewallRuleGroup', 'route53resolver:TagResource'], - read: ['route53resolver:GetFirewallRuleGroupAssociation', 'route53resolver:ListFirewallRuleGroupAssociations', 'route53resolver:ListTagsForResource'], - update: ['route53resolver:TagResource', 'route53resolver:UntagResource'], - delete: ['route53resolver:DisassociateFirewallRuleGroup'], - }, - 'AWS::Route53Resolver::ResolverQueryLoggingConfig': { - create: ['route53resolver:CreateResolverQueryLogConfig', 'route53resolver:TagResource'], - read: ['route53resolver:GetResolverQueryLogConfig', 'route53resolver:ListResolverQueryLogConfigs', 'route53resolver:ListTagsForResource'], - update: ['route53resolver:TagResource', 'route53resolver:UntagResource'], - delete: ['route53resolver:DeleteResolverQueryLogConfig'], - }, - 'AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation': { - create: ['route53resolver:AssociateResolverQueryLogConfig'], - read: ['route53resolver:GetResolverQueryLogConfigAssociation', 'route53resolver:ListResolverQueryLogConfigAssociations'], - update: ['route53resolver:AssociateResolverQueryLogConfig', 'route53resolver:DisassociateResolverQueryLogConfig'], - delete: ['route53resolver:DisassociateResolverQueryLogConfig'], - }, - - // ─── S3 ───────────────────────────────────────────────────────────────────── - 'AWS::S3::Bucket': { - create: ['s3:CreateBucket', 's3:PutBucketPolicy', 's3:PutBucketPublicAccessBlock', 's3:PutEncryptionConfiguration', 's3:PutBucketVersioning', 's3:PutBucketTagging'], - read: ['s3:GetBucketPolicy', 's3:GetBucketTagging', 's3:GetEncryptionConfiguration', 's3:GetBucketVersioning', 's3:GetBucketPublicAccessBlock', 's3:GetBucketLocation', 's3:ListBucket'], - update: ['s3:PutBucketPolicy', 's3:PutBucketPublicAccessBlock', 's3:PutEncryptionConfiguration', 's3:PutBucketVersioning', 's3:PutBucketTagging', 's3:DeleteBucketPolicy'], - delete: ['s3:DeleteBucket', 's3:DeleteBucketPolicy'], - }, - 'AWS::S3::BucketPolicy': { - create: ['s3:PutBucketPolicy'], - read: ['s3:GetBucketPolicy'], - update: ['s3:PutBucketPolicy'], - delete: ['s3:DeleteBucketPolicy'], - }, - - // ─── SQS ──────────────────────────────────────────────────────────────────── - 'AWS::SQS::Queue': { - create: ['sqs:CreateQueue', 'sqs:TagQueue', 'sqs:GetQueueUrl', 'sqs:GetQueueAttributes'], - read: ['sqs:GetQueueAttributes', 'sqs:GetQueueUrl'], - update: ['sqs:SetQueueAttributes', 'sqs:TagQueue', 'sqs:UntagQueue'], - delete: ['sqs:DeleteQueue', 'sqs:GetQueueUrl'], - }, - 'AWS::SQS::QueuePolicy': { - create: ['sqs:AddPermission', 'sqs:SetQueueAttributes', 'sqs:GetQueueUrl'], - read: ['sqs:GetQueueAttributes', 'sqs:GetQueueUrl'], - update: ['sqs:SetQueueAttributes', 'sqs:RemovePermission', 'sqs:AddPermission'], - delete: ['sqs:RemovePermission', 'sqs:SetQueueAttributes', 'sqs:GetQueueUrl'], - }, - - // ─── Secrets Manager ─────────────────────────────────────────────────────── - 'AWS::SecretsManager::Secret': { - create: ['secretsmanager:CreateSecret', 'secretsmanager:TagResource', 'secretsmanager:GetRandomPassword'], - read: ['secretsmanager:DescribeSecret', 'secretsmanager:GetSecretValue', 'secretsmanager:GetResourcePolicy'], - update: ['secretsmanager:UpdateSecret', 'secretsmanager:PutSecretValue', 'secretsmanager:TagResource', 'secretsmanager:UntagResource', 'secretsmanager:PutResourcePolicy', 'secretsmanager:DeleteResourcePolicy'], - delete: ['secretsmanager:DeleteSecret'], - }, +import { + RESOURCE_ACTION_MAP, + actionsForResource, + type ResourceActions, +} from '../resource-action-map'; - // ─── WAFv2 ────────────────────────────────────────────────────────────────── - 'AWS::WAFv2::WebACL': { - create: ['wafv2:CreateWebACL', 'wafv2:TagResource'], - read: ['wafv2:GetWebACL', 'wafv2:ListTagsForResource'], - update: ['wafv2:UpdateWebACL', 'wafv2:TagResource', 'wafv2:UntagResource'], - delete: ['wafv2:DeleteWebACL'], - }, - 'AWS::WAFv2::WebACLAssociation': { - create: ['wafv2:AssociateWebACL'], - read: ['wafv2:GetWebACLForResource'], - update: ['wafv2:AssociateWebACL', 'wafv2:DisassociateWebACL'], - delete: ['wafv2:DisassociateWebACL'], - }, -}; +export { RESOURCE_ACTION_MAP } from '../resource-action-map'; +export type { ResourceActions, LifecyclePhase } from '../resource-action-map'; /** * Returns the ResourceActions entry for a given CloudFormation resource type, @@ -415,12 +48,13 @@ export function getActionsForResource(cfnType: string): ResourceActions | undefi } /** - * Returns the set of all unique IAM actions referenced across all map entries. + * Returns the set of all unique IAM actions referenced across all map entries, + * across every lifecycle phase. */ export function getAllMappedActions(): Set { const actions = new Set(); - for (const entry of Object.values(RESOURCE_ACTION_MAP)) { - for (const action of [...entry.create, ...entry.read, ...entry.update, ...entry.delete]) { + for (const cfnType of Object.keys(RESOURCE_ACTION_MAP)) { + for (const action of actionsForResource(cfnType)) { actions.add(action); } } diff --git a/cdk/src/bootstrap/resource-action-map.ts b/cdk/src/bootstrap/resource-action-map.ts index 31d19c447..17baeceb6 100644 --- a/cdk/src/bootstrap/resource-action-map.ts +++ b/cdk/src/bootstrap/resource-action-map.ts @@ -41,68 +41,591 @@ export const CFN_TYPES_WITHOUT_EXEC_ROLE_IAM = new Set([ ]); /** - * Minimum IAM actions the CloudFormation execution role needs to create each - * resource type. This is a deploy-time subset — update when constructs add new - * AWS services or when CloudTrail shows additional actions during deploy. + * IAM actions the CloudFormation execution role needs per resource lifecycle + * phase. Actions are sourced from the CloudTrail-validated policies in + * docs/design/DEPLOYMENT_ROLES.md. * - * Parent issue: #350. Full synth-time aspect tracked in #125. + * ``create`` is the deploy-verified subset that CI exercises today. The + * ``read``/``update``/``delete`` phases exist because a create-only map cannot + * catch the failure mode that actually recurs: every one of the reactive + * permission fixes since this map was introduced (#351, #403, #405, #408, #410, + * #494, #595) was a missing ``Update*``/``Tag*``/``Delete*`` action, surfaced by + * a failed stack UPDATE or DELETE rather than by the initial deploy. + * + * Parent issue: #350 (map) / #124 (CRUD depth). Synth-time aspect: #125. */ -export const RESOURCE_ACTION_MAP: Record = { - 'AWS::ApiGateway::Authorizer': ['apigateway:POST'], - 'AWS::ApiGateway::Method': ['apigateway:POST'], - 'AWS::ApiGateway::RequestValidator': ['apigateway:POST'], - 'AWS::ApiGateway::Resource': ['apigateway:POST'], - 'AWS::ApiGateway::RestApi': ['apigateway:POST'], - 'AWS::Bedrock::Guardrail': ['bedrock:CreateGuardrail'], - 'AWS::Bedrock::GuardrailVersion': ['bedrock:CreateGuardrailVersion'], - 'AWS::BedrockAgentCore::Memory': ['bedrock-agentcore:CreateMemory'], - 'AWS::BedrockAgentCore::Runtime': ['bedrock-agentcore:CreateRuntime'], - 'AWS::CloudFront::Distribution': ['cloudfront:CreateDistribution'], - 'AWS::CloudFront::OriginAccessControl': ['cloudfront:CreateOriginAccessControl'], - 'AWS::CloudWatch::Alarm': ['cloudwatch:PutMetricAlarm'], - 'AWS::CloudWatch::Dashboard': ['cloudwatch:PutDashboard'], - 'AWS::Cognito::UserPool': ['cognito-idp:CreateUserPool'], - 'AWS::Cognito::UserPoolClient': ['cognito-idp:CreateUserPoolClient'], - 'AWS::DynamoDB::Table': ['dynamodb:CreateTable'], - 'AWS::EC2::EIP': ['ec2:AllocateAddress'], - 'AWS::EC2::FlowLog': ['ec2:CreateFlowLogs'], - 'AWS::EC2::InternetGateway': ['ec2:CreateInternetGateway'], - 'AWS::EC2::NatGateway': ['ec2:CreateNatGateway'], - 'AWS::EC2::Route': ['ec2:CreateRoute'], - 'AWS::EC2::RouteTable': ['ec2:CreateRouteTable'], - 'AWS::EC2::SecurityGroup': ['ec2:CreateSecurityGroup'], - 'AWS::EC2::Subnet': ['ec2:CreateSubnet'], - 'AWS::EC2::VPC': ['ec2:CreateVpc'], - 'AWS::EC2::VPCEndpoint': ['ec2:CreateVpcEndpoint'], - // Only synthesized under `--context compute_type=ecs` (EcsAgentCluster). - // Their absence is what let compute-ecs.ts grant 14 unverified `ecs:*` - // actions — see the ecs synth-coverage test, which now fails loudly (#124). - 'AWS::ECS::Cluster': ['ecs:CreateCluster', 'ecs:TagResource'], - 'AWS::ECS::TaskDefinition': ['ecs:RegisterTaskDefinition', 'ecs:TagResource'], - 'AWS::Events::Rule': ['events:PutRule'], - 'AWS::IAM::Policy': ['iam:CreatePolicy', 'iam:PutRolePolicy'], - 'AWS::IAM::Role': ['iam:CreateRole'], - 'AWS::Lambda::EventInvokeConfig': ['lambda:PutFunctionEventInvokeConfig'], - 'AWS::Lambda::EventSourceMapping': ['lambda:CreateEventSourceMapping'], - 'AWS::Lambda::Function': ['lambda:CreateFunction'], - 'AWS::Lambda::LayerVersion': ['lambda:PublishLayerVersion'], - 'AWS::Logs::Delivery': ['logs:CreateDelivery'], - 'AWS::Logs::DeliveryDestination': ['logs:PutDeliveryDestination'], - 'AWS::Logs::DeliverySource': ['logs:PutDeliverySource'], - 'AWS::Logs::LogGroup': ['logs:CreateLogGroup'], - 'AWS::Route53Resolver::FirewallDomainList': ['route53resolver:CreateFirewallDomainList'], - 'AWS::Route53Resolver::FirewallRuleGroup': ['route53resolver:CreateFirewallRuleGroup'], - 'AWS::Route53Resolver::FirewallRuleGroupAssociation': ['route53resolver:AssociateFirewallRuleGroup'], - 'AWS::Route53Resolver::ResolverQueryLoggingConfig': ['route53resolver:CreateResolverQueryLogConfig'], - 'AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation': ['route53resolver:AssociateResolverQueryLogConfig'], - 'AWS::S3::Bucket': ['s3:CreateBucket'], - 'AWS::SecretsManager::Secret': ['secretsmanager:CreateSecret'], - 'AWS::SQS::Queue': ['sqs:CreateQueue'], - 'AWS::WAFv2::WebACL': ['wafv2:CreateWebACL'], - 'AWS::WAFv2::WebACLAssociation': ['wafv2:AssociateWebACL'], - 'Custom::AWS': ['lambda:InvokeFunction'], - 'Custom::S3AutoDeleteObjects': ['lambda:InvokeFunction'], - 'Custom::VpcRestrictDefaultSG': ['lambda:InvokeFunction'], +export interface ResourceActions { + /** Actions required to create the resource (exercised by CI's deploy). */ + create: readonly string[]; + /** Actions CloudFormation issues to read state back (drift / no-op detection). */ + read: readonly string[]; + /** Actions required to modify the resource in place on a stack update. */ + update: readonly string[]; + /** Actions required to remove the resource on a stack update or delete. */ + delete: readonly string[]; +} + +export const RESOURCE_ACTION_MAP: Record = { + // ─── ApiGateway ──────────────────────────────────────────────────── + 'AWS::ApiGateway::Account': { + create: ['apigateway:PATCH'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH'], + delete: ['apigateway:PATCH'], + }, + 'AWS::ApiGateway::Authorizer': { + create: ['apigateway:POST'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::Deployment': { + create: ['apigateway:POST'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::Method': { + create: ['apigateway:PUT', 'apigateway:POST'], + read: ['apigateway:GET'], + update: ['apigateway:PUT'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::RequestValidator': { + create: ['apigateway:POST'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::Resource': { + create: ['apigateway:POST'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::RestApi': { + create: ['apigateway:POST', 'apigateway:TagResource'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH', 'apigateway:TagResource', 'apigateway:UntagResource'], + delete: ['apigateway:DELETE'], + }, + 'AWS::ApiGateway::Stage': { + create: ['apigateway:POST', 'apigateway:TagResource'], + read: ['apigateway:GET'], + update: ['apigateway:PATCH', 'apigateway:TagResource', 'apigateway:UntagResource'], + delete: ['apigateway:DELETE'], + }, + // ─── Bedrock ─────────────────────────────────────────────────────── + 'AWS::Bedrock::Guardrail': { + create: ['bedrock:CreateGuardrail', 'bedrock:TagResource'], + read: ['bedrock:GetGuardrail', 'bedrock:ListTagsForResource'], + update: ['bedrock:UpdateGuardrail', 'bedrock:TagResource', 'bedrock:UntagResource'], + delete: ['bedrock:DeleteGuardrail'], + }, + 'AWS::Bedrock::GuardrailVersion': { + create: ['bedrock:CreateGuardrailVersion'], + read: ['bedrock:GetGuardrail'], + update: ['bedrock:CreateGuardrailVersion'], + delete: ['bedrock:DeleteGuardrail'], + }, + // ─── BedrockAgentCore ────────────────────────────────────────────── + 'AWS::BedrockAgentCore::Memory': { + create: ['bedrock-agentcore:CreateMemory'], + read: ['bedrock-agentcore:GetMemory'], + update: ['bedrock-agentcore:UpdateMemory'], + delete: ['bedrock-agentcore:DeleteMemory'], + }, + 'AWS::BedrockAgentCore::Runtime': { + create: ['bedrock-agentcore:CreateRuntime'], + read: ['bedrock-agentcore:GetRuntime'], + update: ['bedrock-agentcore:UpdateRuntime'], + delete: ['bedrock-agentcore:DeleteRuntime'], + }, + // ─── CloudFront ──────────────────────────────────────────────────── + 'AWS::CloudFront::Distribution': { + create: ['cloudfront:CreateDistribution'], + read: [], + update: [], + delete: [], + }, + 'AWS::CloudFront::OriginAccessControl': { + create: ['cloudfront:CreateOriginAccessControl'], + read: [], + update: [], + delete: [], + }, + // ─── CloudWatch ──────────────────────────────────────────────────── + 'AWS::CloudWatch::Alarm': { + create: ['cloudwatch:PutMetricAlarm', 'cloudwatch:TagResource'], + read: ['cloudwatch:DescribeAlarms', 'cloudwatch:ListTagsForResource'], + update: ['cloudwatch:PutMetricAlarm', 'cloudwatch:TagResource', 'cloudwatch:UntagResource'], + delete: ['cloudwatch:DeleteAlarms'], + }, + 'AWS::CloudWatch::Dashboard': { + create: ['cloudwatch:PutDashboard'], + read: ['cloudwatch:GetDashboard'], + update: ['cloudwatch:PutDashboard'], + delete: ['cloudwatch:DeleteDashboards'], + }, + // ─── Cognito ─────────────────────────────────────────────────────── + 'AWS::Cognito::UserPool': { + create: ['cognito-idp:CreateUserPool', 'cognito-idp:TagResource'], + read: [ + 'cognito-idp:DescribeUserPool', + 'cognito-idp:ListTagsForResource', + 'cognito-idp:GetUserPoolMfaConfig', + ], + update: ['cognito-idp:UpdateUserPool', 'cognito-idp:TagResource', 'cognito-idp:UntagResource'], + delete: ['cognito-idp:DeleteUserPool'], + }, + 'AWS::Cognito::UserPoolClient': { + create: ['cognito-idp:CreateUserPoolClient'], + read: ['cognito-idp:DescribeUserPoolClient'], + update: ['cognito-idp:UpdateUserPoolClient'], + delete: ['cognito-idp:DeleteUserPoolClient'], + }, + // ─── Custom ──────────────────────────────────────────────────────── + 'Custom::AWS': { + create: ['lambda:InvokeFunction'], + read: [], + update: [], + delete: [], + }, + 'Custom::S3AutoDeleteObjects': { + create: ['lambda:InvokeFunction'], + read: [], + update: [], + delete: [], + }, + 'Custom::VpcRestrictDefaultSG': { + create: ['lambda:InvokeFunction'], + read: [], + update: [], + delete: [], + }, + // ─── DynamoDB ────────────────────────────────────────────────────── + 'AWS::DynamoDB::Table': { + create: [ + 'dynamodb:CreateTable', + 'dynamodb:TagResource', + 'dynamodb:DescribeTable', + 'dynamodb:UpdateTimeToLive', + 'dynamodb:UpdateContinuousBackups', + ], + read: [ + 'dynamodb:DescribeTable', + 'dynamodb:DescribeTimeToLive', + 'dynamodb:DescribeContinuousBackups', + 'dynamodb:ListTagsOfResource', + 'dynamodb:DescribeContributorInsights', + 'dynamodb:DescribeKinesisStreamingDestination', + 'dynamodb:GetResourcePolicy', + ], + update: [ + 'dynamodb:UpdateTable', + 'dynamodb:TagResource', + 'dynamodb:UntagResource', + 'dynamodb:UpdateTimeToLive', + 'dynamodb:UpdateContinuousBackups', + ], + delete: ['dynamodb:DeleteTable'], + }, + // ─── EC2 ─────────────────────────────────────────────────────────── + 'AWS::EC2::EIP': { + create: ['ec2:AllocateAddress', 'ec2:CreateTags'], + read: ['ec2:DescribeAddresses'], + update: ['ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:ReleaseAddress'], + }, + 'AWS::EC2::FlowLog': { + create: ['ec2:CreateFlowLogs', 'ec2:CreateTags'], + read: ['ec2:DescribeFlowLogs'], + update: ['ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteFlowLogs'], + }, + 'AWS::EC2::InternetGateway': { + create: ['ec2:CreateInternetGateway', 'ec2:CreateTags'], + read: ['ec2:DescribeInternetGateways'], + update: ['ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteInternetGateway'], + }, + 'AWS::EC2::NatGateway': { + create: ['ec2:CreateNatGateway', 'ec2:CreateTags'], + read: ['ec2:DescribeNatGateways'], + update: ['ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteNatGateway'], + }, + 'AWS::EC2::Route': { + create: ['ec2:CreateRoute'], + read: ['ec2:DescribeRouteTables'], + update: ['ec2:CreateRoute', 'ec2:DeleteRoute'], + delete: ['ec2:DeleteRoute'], + }, + 'AWS::EC2::RouteTable': { + create: ['ec2:CreateRouteTable', 'ec2:CreateTags'], + read: ['ec2:DescribeRouteTables'], + update: ['ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteRouteTable'], + }, + 'AWS::EC2::SecurityGroup': { + create: [ + 'ec2:CreateSecurityGroup', + 'ec2:CreateTags', + 'ec2:AuthorizeSecurityGroupEgress', + 'ec2:AuthorizeSecurityGroupIngress', + ], + read: ['ec2:DescribeSecurityGroups'], + update: [ + 'ec2:AuthorizeSecurityGroupEgress', + 'ec2:RevokeSecurityGroupEgress', + 'ec2:AuthorizeSecurityGroupIngress', + 'ec2:RevokeSecurityGroupIngress', + 'ec2:CreateTags', + 'ec2:DeleteTags', + ], + delete: ['ec2:DeleteSecurityGroup'], + }, + 'AWS::EC2::Subnet': { + create: ['ec2:CreateSubnet', 'ec2:CreateTags', 'ec2:ModifySubnetAttribute'], + read: ['ec2:DescribeSubnets'], + update: ['ec2:ModifySubnetAttribute', 'ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteSubnet'], + }, + 'AWS::EC2::SubnetRouteTableAssociation': { + create: ['ec2:AssociateRouteTable'], + read: ['ec2:DescribeRouteTables'], + update: ['ec2:AssociateRouteTable', 'ec2:DisassociateRouteTable'], + delete: ['ec2:DisassociateRouteTable'], + }, + 'AWS::EC2::VPC': { + create: [ + 'ec2:CreateVpc', + 'ec2:CreateTags', + 'ec2:ModifyVpcAttribute', + 'ec2:DescribeVpcAttribute', + ], + read: ['ec2:DescribeVpcs', 'ec2:DescribeVpcAttribute'], + update: ['ec2:ModifyVpcAttribute', 'ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteVpc'], + }, + 'AWS::EC2::VPCEndpoint': { + create: ['ec2:CreateVpcEndpoint', 'ec2:CreateTags'], + read: ['ec2:DescribeVpcEndpoints'], + update: ['ec2:ModifyVpcEndpoint', 'ec2:CreateTags', 'ec2:DeleteTags'], + delete: ['ec2:DeleteVpcEndpoints'], + }, + 'AWS::EC2::VPCGatewayAttachment': { + create: ['ec2:AttachInternetGateway'], + read: ['ec2:DescribeInternetGateways'], + update: ['ec2:AttachInternetGateway', 'ec2:DetachInternetGateway'], + delete: ['ec2:DetachInternetGateway'], + }, + // ─── ECS ─────────────────────────────────────────────────────────── + 'AWS::ECS::Cluster': { + create: ['ecs:CreateCluster', 'ecs:TagResource'], + read: [], + update: [], + delete: [], + }, + 'AWS::ECS::TaskDefinition': { + create: ['ecs:RegisterTaskDefinition', 'ecs:TagResource'], + read: [], + update: [], + delete: [], + }, + // ─── Events ──────────────────────────────────────────────────────── + 'AWS::Events::Rule': { + create: ['events:PutRule', 'events:PutTargets', 'events:TagResource'], + read: ['events:DescribeRule', 'events:ListTargetsByRule', 'events:ListTagsForResource'], + update: [ + 'events:PutRule', + 'events:PutTargets', + 'events:RemoveTargets', + 'events:TagResource', + 'events:UntagResource', + ], + delete: ['events:DeleteRule', 'events:RemoveTargets'], + }, + // ─── IAM ─────────────────────────────────────────────────────────── + 'AWS::IAM::ManagedPolicy': { + create: ['iam:CreatePolicy', 'iam:TagPolicy'], + read: ['iam:GetPolicy', 'iam:GetPolicyVersion', 'iam:ListPolicyVersions'], + update: ['iam:CreatePolicyVersion', 'iam:DeletePolicyVersion', 'iam:TagPolicy'], + delete: ['iam:DeletePolicy', 'iam:DeletePolicyVersion'], + }, + 'AWS::IAM::Policy': { + create: ['iam:PutRolePolicy', 'iam:CreatePolicy'], + read: ['iam:GetRolePolicy'], + update: ['iam:PutRolePolicy'], + delete: ['iam:DeleteRolePolicy'], + }, + 'AWS::IAM::Role': { + create: [ + 'iam:CreateRole', + 'iam:TagRole', + 'iam:AttachRolePolicy', + 'iam:PutRolePolicy', + 'iam:PassRole', + ], + read: [ + 'iam:GetRole', + 'iam:ListRoleTags', + 'iam:ListRolePolicies', + 'iam:ListAttachedRolePolicies', + 'iam:GetRolePolicy', + 'iam:ListInstanceProfilesForRole', + ], + update: [ + 'iam:UpdateRole', + 'iam:TagRole', + 'iam:UntagRole', + 'iam:AttachRolePolicy', + 'iam:DetachRolePolicy', + 'iam:PutRolePolicy', + 'iam:DeleteRolePolicy', + ], + delete: ['iam:DeleteRole', 'iam:DetachRolePolicy', 'iam:DeleteRolePolicy'], + }, + // ─── Lambda ──────────────────────────────────────────────────────── + 'AWS::Lambda::Alias': { + create: ['lambda:CreateAlias'], + read: ['lambda:GetAlias'], + update: ['lambda:UpdateAlias'], + delete: ['lambda:DeleteAlias'], + }, + 'AWS::Lambda::EventInvokeConfig': { + create: ['lambda:PutFunctionEventInvokeConfig'], + read: ['lambda:GetFunctionEventInvokeConfig'], + update: ['lambda:PutFunctionEventInvokeConfig'], + delete: ['lambda:DeleteFunctionEventInvokeConfig'], + }, + 'AWS::Lambda::EventSourceMapping': { + create: ['lambda:CreateEventSourceMapping'], + read: ['lambda:GetEventSourceMapping'], + update: ['lambda:UpdateEventSourceMapping'], + delete: ['lambda:DeleteEventSourceMapping'], + }, + 'AWS::Lambda::Function': { + create: ['lambda:CreateFunction', 'lambda:TagResource'], + read: [ + 'lambda:GetFunction', + 'lambda:GetFunctionConfiguration', + 'lambda:GetPolicy', + 'lambda:ListTags', + 'lambda:GetFunctionCodeSigningConfig', + 'lambda:GetFunctionRecursionConfig', + 'lambda:GetRuntimeManagementConfig', + ], + update: [ + 'lambda:UpdateFunctionCode', + 'lambda:UpdateFunctionConfiguration', + 'lambda:TagResource', + 'lambda:UntagResource', + 'lambda:PutFunctionConcurrency', + 'lambda:DeleteFunctionConcurrency', + ], + delete: ['lambda:DeleteFunction'], + }, + 'AWS::Lambda::LayerVersion': { + create: ['lambda:PublishLayerVersion'], + read: ['lambda:GetLayerVersion'], + update: ['lambda:PublishLayerVersion'], + delete: ['lambda:DeleteLayerVersion'], + }, + 'AWS::Lambda::Permission': { + create: ['lambda:AddPermission'], + read: ['lambda:GetPolicy'], + update: ['lambda:AddPermission', 'lambda:RemovePermission'], + delete: ['lambda:RemovePermission'], + }, + 'AWS::Lambda::Version': { + create: ['lambda:PublishVersion'], + read: ['lambda:GetFunction', 'lambda:GetProvisionedConcurrencyConfig'], + update: ['lambda:PublishVersion'], + delete: ['lambda:DeleteFunction'], + }, + // ─── Logs ────────────────────────────────────────────────────────── + 'AWS::Logs::Delivery': { + create: ['logs:CreateDelivery'], + read: ['logs:GetDelivery', 'logs:DescribeDeliveries'], + update: ['logs:CreateDelivery', 'logs:DeleteDelivery'], + delete: ['logs:DeleteDelivery'], + }, + 'AWS::Logs::DeliveryDestination': { + create: ['logs:PutDeliveryDestination'], + read: ['logs:GetDeliveryDestination', 'logs:GetDeliveryDestinationPolicy'], + update: ['logs:PutDeliveryDestination'], + delete: ['logs:DeleteDeliveryDestination'], + }, + 'AWS::Logs::DeliverySource': { + create: ['logs:PutDeliverySource'], + read: ['logs:GetDeliverySource'], + update: ['logs:PutDeliverySource'], + delete: ['logs:DeleteDeliverySource'], + }, + 'AWS::Logs::LogGroup': { + create: ['logs:CreateLogGroup', 'logs:TagResource', 'logs:PutRetentionPolicy'], + read: ['logs:DescribeLogGroups', 'logs:ListTagsForResource', 'logs:ListTagsLogGroup'], + update: [ + 'logs:PutRetentionPolicy', + 'logs:DeleteRetentionPolicy', + 'logs:TagResource', + 'logs:UntagResource', + ], + delete: ['logs:DeleteLogGroup'], + }, + 'AWS::Logs::ResourcePolicy': { + create: ['logs:PutResourcePolicy'], + read: ['logs:DescribeResourcePolicies'], + update: ['logs:PutResourcePolicy'], + delete: ['logs:DeleteResourcePolicy'], + }, + // ─── Route53Resolver ─────────────────────────────────────────────── + 'AWS::Route53Resolver::FirewallDomainList': { + create: ['route53resolver:CreateFirewallDomainList', 'route53resolver:TagResource'], + read: ['route53resolver:GetFirewallDomainList', 'route53resolver:ListTagsForResource'], + update: [ + 'route53resolver:UpdateFirewallDomains', + 'route53resolver:TagResource', + 'route53resolver:UntagResource', + ], + delete: ['route53resolver:DeleteFirewallDomainList'], + }, + 'AWS::Route53Resolver::FirewallRuleGroup': { + create: [ + 'route53resolver:CreateFirewallRuleGroup', + 'route53resolver:CreateFirewallRule', + 'route53resolver:TagResource', + ], + read: [ + 'route53resolver:GetFirewallRuleGroup', + 'route53resolver:ListFirewallRules', + 'route53resolver:ListTagsForResource', + ], + update: [ + 'route53resolver:UpdateFirewallRule', + 'route53resolver:CreateFirewallRule', + 'route53resolver:DeleteFirewallRule', + 'route53resolver:TagResource', + 'route53resolver:UntagResource', + ], + delete: ['route53resolver:DeleteFirewallRuleGroup', 'route53resolver:DeleteFirewallRule'], + }, + 'AWS::Route53Resolver::FirewallRuleGroupAssociation': { + create: ['route53resolver:AssociateFirewallRuleGroup', 'route53resolver:TagResource'], + read: [ + 'route53resolver:GetFirewallRuleGroupAssociation', + 'route53resolver:ListFirewallRuleGroupAssociations', + 'route53resolver:ListTagsForResource', + ], + update: ['route53resolver:TagResource', 'route53resolver:UntagResource'], + delete: ['route53resolver:DisassociateFirewallRuleGroup'], + }, + 'AWS::Route53Resolver::ResolverQueryLoggingConfig': { + create: ['route53resolver:CreateResolverQueryLogConfig', 'route53resolver:TagResource'], + read: [ + 'route53resolver:GetResolverQueryLogConfig', + 'route53resolver:ListResolverQueryLogConfigs', + 'route53resolver:ListTagsForResource', + ], + update: ['route53resolver:TagResource', 'route53resolver:UntagResource'], + delete: ['route53resolver:DeleteResolverQueryLogConfig'], + }, + 'AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation': { + create: ['route53resolver:AssociateResolverQueryLogConfig'], + read: [ + 'route53resolver:GetResolverQueryLogConfigAssociation', + 'route53resolver:ListResolverQueryLogConfigAssociations', + ], + update: [ + 'route53resolver:AssociateResolverQueryLogConfig', + 'route53resolver:DisassociateResolverQueryLogConfig', + ], + delete: ['route53resolver:DisassociateResolverQueryLogConfig'], + }, + // ─── S3 ──────────────────────────────────────────────────────────── + 'AWS::S3::Bucket': { + create: [ + 's3:CreateBucket', + 's3:PutBucketPolicy', + 's3:PutBucketPublicAccessBlock', + 's3:PutEncryptionConfiguration', + 's3:PutBucketVersioning', + 's3:PutBucketTagging', + ], + read: [ + 's3:GetBucketPolicy', + 's3:GetBucketTagging', + 's3:GetEncryptionConfiguration', + 's3:GetBucketVersioning', + 's3:GetBucketPublicAccessBlock', + 's3:GetBucketLocation', + 's3:ListBucket', + ], + update: [ + 's3:PutBucketPolicy', + 's3:PutBucketPublicAccessBlock', + 's3:PutEncryptionConfiguration', + 's3:PutBucketVersioning', + 's3:PutBucketTagging', + 's3:DeleteBucketPolicy', + ], + delete: ['s3:DeleteBucket', 's3:DeleteBucketPolicy'], + }, + 'AWS::S3::BucketPolicy': { + create: ['s3:PutBucketPolicy'], + read: ['s3:GetBucketPolicy'], + update: ['s3:PutBucketPolicy'], + delete: ['s3:DeleteBucketPolicy'], + }, + // ─── SQS ─────────────────────────────────────────────────────────── + 'AWS::SQS::Queue': { + create: ['sqs:CreateQueue', 'sqs:TagQueue', 'sqs:GetQueueUrl', 'sqs:GetQueueAttributes'], + read: ['sqs:GetQueueAttributes', 'sqs:GetQueueUrl'], + update: ['sqs:SetQueueAttributes', 'sqs:TagQueue', 'sqs:UntagQueue'], + delete: ['sqs:DeleteQueue', 'sqs:GetQueueUrl'], + }, + 'AWS::SQS::QueuePolicy': { + create: ['sqs:AddPermission', 'sqs:SetQueueAttributes', 'sqs:GetQueueUrl'], + read: ['sqs:GetQueueAttributes', 'sqs:GetQueueUrl'], + update: ['sqs:SetQueueAttributes', 'sqs:RemovePermission', 'sqs:AddPermission'], + delete: ['sqs:RemovePermission', 'sqs:SetQueueAttributes', 'sqs:GetQueueUrl'], + }, + // ─── SecretsManager ──────────────────────────────────────────────── + 'AWS::SecretsManager::Secret': { + create: [ + 'secretsmanager:CreateSecret', + 'secretsmanager:TagResource', + 'secretsmanager:GetRandomPassword', + ], + read: [ + 'secretsmanager:DescribeSecret', + 'secretsmanager:GetSecretValue', + 'secretsmanager:GetResourcePolicy', + ], + update: [ + 'secretsmanager:UpdateSecret', + 'secretsmanager:PutSecretValue', + 'secretsmanager:TagResource', + 'secretsmanager:UntagResource', + 'secretsmanager:PutResourcePolicy', + 'secretsmanager:DeleteResourcePolicy', + ], + delete: ['secretsmanager:DeleteSecret'], + }, + // ─── WAFv2 ───────────────────────────────────────────────────────── + 'AWS::WAFv2::WebACL': { + create: ['wafv2:CreateWebACL', 'wafv2:TagResource'], + read: ['wafv2:GetWebACL', 'wafv2:ListTagsForResource'], + update: ['wafv2:UpdateWebACL', 'wafv2:TagResource', 'wafv2:UntagResource'], + delete: ['wafv2:DeleteWebACL'], + }, + 'AWS::WAFv2::WebACLAssociation': { + create: ['wafv2:AssociateWebACL'], + read: ['wafv2:GetWebACLForResource'], + update: ['wafv2:AssociateWebACL', 'wafv2:DisassociateWebACL'], + delete: ['wafv2:DisassociateWebACL'], + }, }; /** @@ -152,19 +675,37 @@ export function collectBootstrapAllowActions(computeType?: string): Set return actions; } +/** Lifecycle phases a resource's required actions are grouped under. */ +export type LifecyclePhase = keyof ResourceActions; + +/** Flattens an entry's phases into one action list (deduplicated, order kept). */ +export function actionsForResource( + cfnType: string, + phases: readonly LifecyclePhase[] = ['create', 'read', 'update', 'delete'], +): string[] { + const entry = RESOURCE_ACTION_MAP[cfnType]; + if (!entry) { + return []; + } + return [...new Set(phases.flatMap((phase) => entry[phase]))]; +} + /** * Returns IAM actions required by a CloudFormation type that are not covered * by the bootstrap policy bundle. + * + * Defaults to the ``create`` phase only, which preserves the pre-CRUD contract + * for existing callers (CI validates a deploy, not an update/delete). Pass + * explicit ``phases`` to widen: `['create', 'update', 'delete']` is what would + * have caught the reactive fixes listed on {@link RESOURCE_ACTION_MAP}, and is + * only safe to gate on once those grants exist (tracked in #124). */ export function findMissingBootstrapActions( cfnType: string, allowedActions: Set, + phases: readonly LifecyclePhase[] = ['create'], ): string[] { - const required = RESOURCE_ACTION_MAP[cfnType]; - if (!required) { - return []; - } - return required.filter((req) => + return actionsForResource(cfnType, phases).filter((req) => ![...allowedActions].some((allowed) => actionIsAllowed(req, allowed)), ); } diff --git a/cdk/test/bootstrap/resource-action-map.test.ts b/cdk/test/bootstrap/resource-action-map.test.ts index 14911c274..62dfa6394 100644 --- a/cdk/test/bootstrap/resource-action-map.test.ts +++ b/cdk/test/bootstrap/resource-action-map.test.ts @@ -17,11 +17,6 @@ * SOFTWARE. */ -import { execFileSync } from 'node:child_process'; -import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - import { Stack } from 'aws-cdk-lib'; import { allPolicies } from '../../src/bootstrap/policies'; @@ -61,31 +56,6 @@ function extractPolicyActions(): { actions: Set; wildcardPrefixes: Set { describe('map structure', () => { it('has entries for at least 55 resource types', () => { @@ -104,6 +74,31 @@ describe('resource-action-map', () => { } }); + it('every entry declares all four lifecycle phases as arrays', () => { + // The map was create-only (`readonly string[]`) before #124. A regression + // to that shape, or a hand-added entry that omits a phase, would make + // actionsForResource() silently skip it rather than fail. + for (const entry of Object.values(RESOURCE_ACTION_MAP)) { + for (const phase of ['create', 'read', 'update', 'delete'] as const) { + expect(Array.isArray(entry[phase])).toBe(true); + } + expect(Object.keys(entry).sort()).toEqual(['create', 'delete', 'read', 'update']); + } + }); + + it('carries genuine update/delete depth, not just create', () => { + // The point of the CRUD shape: every reactive permission fix since the map + // landed (#351, #403, #405, #408, #410, #494, #595) was a missing + // Update*/Tag*/Delete* action. A create-only map cannot express those, so + // pin that the depth exists and cannot quietly erode back. + const withUpdate = Object.values(RESOURCE_ACTION_MAP) + .filter((e) => e.update.length > 0).length; + const withDelete = Object.values(RESOURCE_ACTION_MAP) + .filter((e) => e.delete.length > 0).length; + expect(withUpdate).toBeGreaterThanOrEqual(45); + expect(withDelete).toBeGreaterThanOrEqual(45); + }); + it('all actions use valid IAM format (service:ActionName) or wildcard (service:*)', () => { // Standard format: lowercase-service-with-hyphens : PascalCaseAction const validFormat = /^[a-z][a-z0-9-]*:[A-Z][A-Za-z0-9]*$/; @@ -126,18 +121,13 @@ describe('resource-action-map', () => { }); describe('policy coverage', () => { - it('all mapped actions (excluding known gaps) exist in the combined policy set', () => { + it('EVERY mapped action, in every lifecycle phase, exists in the policy set', () => { const { actions: policyActions, wildcardPrefixes } = extractPolicyActions(); const mappedActions = getAllMappedActions(); const uncovered: string[] = []; for (const action of mappedActions) { - // Skip known-gap services const service = action.split(':')[0]; - if (KNOWN_GAP_SERVICES.has(service)) continue; - - // Skip known-gap individual actions - if (KNOWN_GAP_ACTIONS.has(action)) continue; // Check direct match if (policyActions.has(action)) continue; @@ -189,51 +179,3 @@ describe('resource-action-map', () => { }); }); }); - -describe('Synth coverage', () => { - const SKIP_TYPES = new Set([ - 'AWS::CDK::Metadata', - 'Custom::AWS', - 'Custom::S3AutoDeleteObjects', - 'Custom::VpcRestrictDefaultSG', - ]); - - function getResourceTypes(templatePath: string): string[] { - if (!existsSync(templatePath)) return []; - const template = JSON.parse(readFileSync(templatePath, 'utf-8')); - const resources = template.Resources as Record; - return [...new Set(Object.values(resources).map(r => r.Type))]; - } - - it('all agentcore resource types have map entries', () => { - const templatePath = join(__dirname, '..', '..', 'cdk.out', 'backgroundagent-dev.template.json'); - const types = getResourceTypes(templatePath); - if (types.length === 0) return; - const unmapped = types.filter(t => !SKIP_TYPES.has(t) && !RESOURCE_ACTION_MAP[t]); - expect(unmapped).toEqual([]); - }); - - it('all ecs resource types have map entries', () => { - // Use a temp dir outside the repo tree to avoid racing with parallel - // tests that fingerprint repoRoot (e.g. github-tags.test.ts via - // AgentRuntimeArtifact.fromAsset). A synth.lock inside the repo tree - // causes ENOENT when another worker stats it mid-lifecycle. - const ecsOutDir = mkdtempSync(join(tmpdir(), 'cdk-ecs-synth-')); - try { - execFileSync('npx', ['cdk', 'synth', '-q', '-c', 'compute_type=ecs', '-o', ecsOutDir], { - cwd: join(__dirname, '..', '..'), - stdio: 'pipe', - timeout: 120000, - }); - } catch { - rmSync(ecsOutDir, { recursive: true, force: true }); - return; // synth unavailable — skip gracefully - } - const ecsTemplatePath = join(ecsOutDir, 'backgroundagent-dev.template.json'); - const types = getResourceTypes(ecsTemplatePath); - rmSync(ecsOutDir, { recursive: true, force: true }); - if (types.length === 0) return; - const unmapped = types.filter(t => !SKIP_TYPES.has(t) && !RESOURCE_ACTION_MAP[t]); - expect(unmapped).toEqual([]); - }); -}); From 75cfb0901c11e569dc1dc6c106f917a2e4501496 Mon Sep 17 00:00:00 2001 From: scottschreckengaust <345885+scottschreckengaust@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:43:42 +0000 Subject: [PATCH 11/11] fix(build): exclude .jest-cache from the image asset fingerprint (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new in-process ECS synth-coverage test builds the agent DockerImageAsset, which fingerprints the whole repo root. Jest workers create and evict `.jest-cache/jest-transform-cache-*//_.map.` entries throughout a run, so the fingerprint walk can hit a path another worker just deleted: Resolution error: ENOENT: no such file or directory, open '.../cdk/.jest-cache/jest-transform-cache-.../80/denytasktest_....map.588130630' Intermittent — it surfaced once in a full `mise run build` and did not reproduce across three cold-cache runs, which is exactly why it needs a structural fix rather than a retry. This is the same vanishing-file class .dockerignore already documents for pytest-cov's `.coverage...` temp files, with the same consequence. `.jest-cache` was in .gitignore but not .dockerignore, and .dockerignore is what CDK's fingerprint honours. Verified by synthesizing the stack and asserting no staged asset directory contains `cdk/.jest-cache` (5 asset dirs, none leaked). Full build now passes cdk 178 suites / 3533 tests and cli 55 / 695. Note: `//cdk:synth:quiet` still fails locally on `ec2:DescribeAvailabilityZones` — an IAM gap in my sandbox account, reproduced identically on a near-main branch, unrelated to this change. Co-Authored-By: Claude --- .dockerignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.dockerignore b/.dockerignore index 364b7a210..9c05168b0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -59,6 +59,14 @@ coverage/ .coverage.* **/.coverage **/.coverage.* +# Jest transform cache — same vanishing-file class as the coverage files above, +# and the same consequence. Jest workers write and evict +# ``.jest-cache/jest-transform-cache-*//_.map.`` entries +# throughout a run, so a suite that synthesizes the agent image asset (which +# fingerprints this tree) can hit ENOENT on an entry another worker just evicted. +# Already in .gitignore, but .dockerignore is what CDK's fingerprint honours. +.jest-cache/ +**/.jest-cache/ # IDE / OS .idea/