diff --git a/.dockerignore b/.dockerignore index 32526d373..9c05168b0 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/ @@ -58,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/ 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/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/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/preflight/index.ts b/cdk/src/bootstrap/preflight/index.ts new file mode 100644 index 000000000..1108f1bd6 --- /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, 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 new file mode 100644 index 000000000..61a7299fc --- /dev/null +++ b/cdk/src/bootstrap/preflight/resource-action-map.ts @@ -0,0 +1,62 @@ +/** + * 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. + */ + +/** + * 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. + */ + +import { + RESOURCE_ACTION_MAP, + actionsForResource, + type ResourceActions, +} from '../resource-action-map'; + +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, + * 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, + * across every lifecycle phase. + */ +export function getAllMappedActions(): Set { + const actions = new Set(); + for (const cfnType of Object.keys(RESOURCE_ACTION_MAP)) { + for (const action of actionsForResource(cfnType)) { + 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 000000000..48055bb1a --- /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/bootstrap/resource-action-map.ts b/cdk/src/bootstrap/resource-action-map.ts index f27394961..17baeceb6 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 @@ -41,63 +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'], - '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'], + }, }; /** @@ -117,10 +645,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') { @@ -137,19 +675,37 @@ export function collectBootstrapAllowActions(): 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/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/cdk/test/bootstrap/required-policies.test.ts b/cdk/test/bootstrap/required-policies.test.ts new file mode 100644 index 000000000..b27bd0bd8 --- /dev/null +++ b/cdk/test/bootstrap/required-policies.test.ts @@ -0,0 +1,96 @@ +/** + * 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 { 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', () => { + 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'); + }); + + 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/resource-action-map.test.ts b/cdk/test/bootstrap/resource-action-map.test.ts new file mode 100644 index 000000000..62dfa6394 --- /dev/null +++ b/cdk/test/bootstrap/resource-action-map.test.ts @@ -0,0 +1,181 @@ +/** + * 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 }; +} + +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('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]*$/; + // 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('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) { + const service = action.split(':')[0]; + + // 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); + }); + }); +}); 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', 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",