diff --git a/cdk/src/bootstrap/index.ts b/cdk/src/bootstrap/index.ts index 68432c5..fa87187 100644 --- a/cdk/src/bootstrap/index.ts +++ b/cdk/src/bootstrap/index.ts @@ -19,3 +19,4 @@ export { infrastructurePolicy, applicationPolicy, observabilityPolicy, allPolicies } from './policies'; export { BOOTSTRAP_VERSION, computeBootstrapHash } from './version'; +export { getRequiredBootstrapPolicies } from './required-policies'; diff --git a/cdk/src/bootstrap/preflight/index.ts b/cdk/src/bootstrap/preflight/index.ts new file mode 100644 index 0000000..7d317ac --- /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 0000000..29a441a --- /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/src/bootstrap/required-policies.ts b/cdk/src/bootstrap/required-policies.ts new file mode 100644 index 0000000..48055bb --- /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', +] as const; + +const COMPUTE_VARIANT_POLICIES: Record = { + agentcore: ['compute-agentcore'], + 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/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index cb5765a..f0257fc 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -23,8 +23,7 @@ import * as bedrock from '@aws-cdk/aws-bedrock-alpha'; import * as agentcoremixins from '@aws-cdk/mixins-preview/aws-bedrockagentcore'; import { ArnFormat, AspectPriority, Aspects, Stack, StackProps, RemovalPolicy, CfnOutput, CfnResource, Duration, Fn, Lazy } from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; -// ecr_assets import is only needed when the ECS block below is uncommented -// import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; +import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; @@ -38,12 +37,12 @@ import { Blueprint } from '../constructs/blueprint'; import { CedarWasmLayer } from '../constructs/cedar-wasm-layer'; import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; import { DnsFirewall } from '../constructs/dns-firewall'; +import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; import { FanOutConsumer } from '../constructs/fanout-consumer'; import { LinearIntegration } from '../constructs/linear-integration'; import { RepoTable } from '../constructs/repo-table'; import { SlackIntegration } from '../constructs/slack-integration'; import { StrandedTaskReconciler } from '../constructs/stranded-task-reconciler'; -// import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; import { TaskApi } from '../constructs/task-api'; import { TaskApprovalsTable } from '../constructs/task-approvals-table'; import { TaskDashboard } from '../constructs/task-dashboard'; @@ -519,26 +518,25 @@ export class AgentStack extends Stack { description: 'Name of the S3 bucket storing --trace trajectory artifacts (design §10.1)', }); - // --- ECS Fargate compute backend (optional) --- - // To enable ECS as an alternative compute backend, uncomment the block below - // and the EcsAgentCluster import at the top of this file. Repos can then use - // compute_type: 'ecs' in their blueprint config to route tasks to ECS Fargate. - // - // const agentImageAsset = new ecr_assets.DockerImageAsset(this, 'AgentImage', { - // directory: repoRoot, - // file: 'agent/Dockerfile', - // platform: ecr_assets.Platform.LINUX_ARM64, - // }); - // - // const ecsCluster = new EcsAgentCluster(this, 'EcsAgentCluster', { - // vpc: agentVpc.vpc, - // agentImageAsset, - // taskTable: taskTable.table, - // taskEventsTable: taskEventsTable.table, - // userConcurrencyTable: userConcurrencyTable.table, - // githubTokenSecret, - // memoryId: agentMemory.memory.memoryId, - // }); + // --- ECS Fargate compute backend (enabled when compute_type=ecs) --- + const computeType = this.node.tryGetContext('compute_type') ?? 'agentcore'; + if (computeType === 'ecs') { + const agentImageAsset = new ecr_assets.DockerImageAsset(this, 'AgentImage', { + directory: repoRoot, + file: 'agent/Dockerfile', + platform: ecr_assets.Platform.LINUX_ARM64, + }); + + new EcsAgentCluster(this, 'EcsAgentCluster', { + vpc: agentVpc.vpc, + agentImageAsset, + taskTable: taskTable.table, + taskEventsTable: taskEventsTable.table, + userConcurrencyTable: userConcurrencyTable.table, + githubTokenSecret, + memoryId: agentMemory.memory.memoryId, + }); + } // --- Task Orchestrator (durable Lambda function) --- const orchestrator = new TaskOrchestrator(this, 'TaskOrchestrator', { diff --git a/cdk/test/bootstrap/required-policies.test.ts b/cdk/test/bootstrap/required-policies.test.ts new file mode 100644 index 0000000..8755e05 --- /dev/null +++ b/cdk/test/bootstrap/required-policies.test.ts @@ -0,0 +1,49 @@ +/** + * 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 agentcore type', () => { + const result = getRequiredBootstrapPolicies('agentcore'); + expect(result).toEqual(['infrastructure', 'application', 'observability', 'compute-agentcore']); + }); + + it('returns core policies plus compute-ecs for ecs type', () => { + const result = getRequiredBootstrapPolicies('ecs'); + expect(result).toEqual(['infrastructure', 'application', 'observability', 'compute-ecs']); + expect(result).not.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 only core policies for unknown compute type', () => { + const result = getRequiredBootstrapPolicies('unknown'); + expect(result).toEqual(['infrastructure', 'application', 'observability']); + expect(result).not.toContain('compute-ecs'); + expect(result).not.toContain('compute-agentcore'); + }); +}); 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 0000000..faed603 --- /dev/null +++ b/cdk/test/bootstrap/resource-action-map.test.ts @@ -0,0 +1,235 @@ +/** + * 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 { 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'; +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); + }); + }); +}); + +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([]); + }); +});