diff --git a/packages/@aws-cdk/aws-iam/README.md b/packages/@aws-cdk/aws-iam/README.md index 12b5d163d485f..b63b628a731a3 100644 --- a/packages/@aws-cdk/aws-iam/README.md +++ b/packages/@aws-cdk/aws-iam/README.md @@ -416,6 +416,96 @@ User). Permissions Boundaries are typically created by account Administrators, and their use on newly created `Role`s will be enforced by IAM policies. +### Bootstrap Permissions Boundary + +If a permissions boundary has been enforced as part of CDK bootstrap, all IAM +Roles and Users that are created as part of the CDK application must be created +with the permissions boundary attached. The most common scenario will be to +apply the enforced permissions boundary to the entire CDK app. This can be done +either by adding the value to `cdk.json` or directly in the `App` constructor. + +For example if your organization has created and is enforcing a permissions +boundary with the name +`cdk-${Qualifier}-PermissionsBoundary` + +```json +{ + "context": { + "@aws-cdk/core:permissionsBoundary": { + "name": "cdk-${Qualifier}-PermissionsBoundary" + } + } +} +``` + +OR + +```ts +new App({ + context: { + [PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { + name: 'cdk-${Qualifier}-PermissionsBoundary', + }, + }, +}); +``` + +Another scenario might be if your organization enforces different permissions +boundaries for different environments. For example your CDK application may have + +* `DevStage` that deploys to a personal dev environment where you have elevated +privileges +* `BetaStage` that deploys to a beta environment which and has a relaxed + permissions boundary +* `GammaStage` that deploys to a gamma environment which has the prod + permissions boundary +* `ProdStage` that deploys to the prod environment and has the prod permissions + boundary + +```ts +declare const app: App; + +new Stage(app, 'DevStage'); + +new Stage(app, 'BetaStage', { + permissionsBoundary: PermissionsBoundary.fromName('beta-permissions-boundary'), +}); + +new Stage(app, 'GammaStage', { + permissionsBoundary: PermissionsBoundary.fromName('prod-permissions-boundary'), +}); + +new Stage(app, 'ProdStage', { + permissionsBoundary: PermissionsBoundary.fromName('prod-permissions-boundary'), +}); +``` + +The provided name can include placeholders for the partition, region, qualifier, and account +These placeholders will be replaced with the actual values if available. This requires +that the Stack has the environment specified, it does not work with environment. + +* '${AWS::Partition}' +* '${AWS::Region}' +* '${AWS::AccountId}' +* '${Qualifier}' + + +```ts +declare const app: App; + +const prodStage = new Stage(app, 'ProdStage', { + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), +}); + +new Stack(prodStage, 'ProdStack', { + synthesizer: new DefaultStackSynthesizer({ + qualifier: 'custom', + }); +}); +``` + +### Custom Permissions Boundary + It is possible to attach Permissions Boundaries to all Roles created in a construct tree all at once: diff --git a/packages/@aws-cdk/aws-iam/package.json b/packages/@aws-cdk/aws-iam/package.json index 35b9f58e15fd1..faef320977602 100644 --- a/packages/@aws-cdk/aws-iam/package.json +++ b/packages/@aws-cdk/aws-iam/package.json @@ -83,6 +83,7 @@ "@aws-cdk/assertions": "0.0.0", "@aws-cdk/cdk-build-tools": "0.0.0", "@aws-cdk/integ-runner": "0.0.0", + "@aws-cdk/integ-tests": "0.0.0", "@aws-cdk/cfn2ts": "0.0.0", "@aws-cdk/pkglint": "0.0.0", "@types/aws-lambda": "^8.10.108", diff --git a/packages/@aws-cdk/aws-iam/rosetta/default.ts-fixture b/packages/@aws-cdk/aws-iam/rosetta/default.ts-fixture index ebfb629462fba..11929a1bdb7bb 100644 --- a/packages/@aws-cdk/aws-iam/rosetta/default.ts-fixture +++ b/packages/@aws-cdk/aws-iam/rosetta/default.ts-fixture @@ -1,5 +1,13 @@ import { Construct } from 'constructs'; -import { CustomResource, Stack, App } from '@aws-cdk/core'; +import { + CustomResource, + Stack, + App, + DefaultStackSynthesizer, + Stage, + PermissionsBoundary, + PERMISSIONS_BOUNDARY_CONTEXT_KEY, +} from '@aws-cdk/core'; import * as codepipeline from '@aws-cdk/aws-codepipeline'; import * as s3 from '@aws-cdk/aws-s3'; import * as dynamodb from '@aws-cdk/aws-dynamodb'; diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/cdk.out b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/cdk.out new file mode 100644 index 0000000000000..8ecc185e9dbee --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/cdk.out @@ -0,0 +1 @@ +{"version":"21.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary-support.assets.json b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary-support.assets.json new file mode 100644 index 0000000000000..5d5dd6bdc2d5c --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary-support.assets.json @@ -0,0 +1,19 @@ +{ + "version": "21.0.0", + "files": { + "988706b46935e3999d78ec8a91b4db6c7d516d3fb99071d2f2c2c6e0c5dc507e": { + "source": { + "path": "integ-permissions-boundary-support.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "988706b46935e3999d78ec8a91b4db6c7d516d3fb99071d2f2c2c6e0c5dc507e.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary-support.template.json b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary-support.template.json new file mode 100644 index 0000000000000..bf8aeaedee357 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary-support.template.json @@ -0,0 +1,70 @@ +{ + "Resources": { + "PB13A4860B": { + "Type": "AWS::IAM::ManagedPolicy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "*", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "Description": "", + "ManagedPolicyName": { + "Fn::Join": [ + "", + [ + "cdk-hnb659fds-PermissionsBoundary-", + { + "Ref": "AWS::AccountId" + }, + "-", + { + "Ref": "AWS::Region" + } + ] + ] + }, + "Path": "/" + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary.assets.json b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary.assets.json new file mode 100644 index 0000000000000..a6bf5f73331aa --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary.assets.json @@ -0,0 +1,20 @@ +{ + "version": "21.0.0", + "files": { + "8e762109b39d7f7170aaa0acabaf6222a2b8f707b4c979de97254aab0338a069": { + "source": { + "path": "integ-permissions-boundary.template.json", + "packaging": "file" + }, + "destinations": { + "12345678-test-region": { + "bucketName": "cdk-hnb659fds-assets-12345678-test-region", + "objectKey": "8e762109b39d7f7170aaa0acabaf6222a2b8f707b4c979de97254aab0338a069.json", + "region": "test-region", + "assumeRoleArn": "arn:${AWS::Partition}:iam::12345678:role/cdk-hnb659fds-file-publishing-role-12345678-test-region" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary.template.json b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary.template.json new file mode 100644 index 0000000000000..e9fb97655d50e --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary.template.json @@ -0,0 +1,67 @@ +{ + "Resources": { + "TestRole6C9272DF": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "sqs.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "PermissionsBoundary": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::12345678:policy/cdk-hnb659fds-PermissionsBoundary-12345678-test-region" + ] + ] + } + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ.json b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ.json new file mode 100644 index 0000000000000..8945abd313413 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ.json @@ -0,0 +1,13 @@ +{ + "enableLookups": true, + "version": "21.0.0", + "testCases": { + "integ-test/DefaultTest": { + "stacks": [ + "integ-permissions-boundary" + ], + "assertionStack": "integ-test/DefaultTest/DeployAssert", + "assertionStackName": "integtestDefaultTestDeployAssert24D5C536" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integtestDefaultTestDeployAssert24D5C536.assets.json b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integtestDefaultTestDeployAssert24D5C536.assets.json new file mode 100644 index 0000000000000..c6322e79691df --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integtestDefaultTestDeployAssert24D5C536.assets.json @@ -0,0 +1,19 @@ +{ + "version": "21.0.0", + "files": { + "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": { + "source": { + "path": "integtestDefaultTestDeployAssert24D5C536.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integtestDefaultTestDeployAssert24D5C536.template.json b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integtestDefaultTestDeployAssert24D5C536.template.json new file mode 100644 index 0000000000000..ad9d0fb73d1dd --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integtestDefaultTestDeployAssert24D5C536.template.json @@ -0,0 +1,36 @@ +{ + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/manifest.json b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/manifest.json new file mode 100644 index 0000000000000..5156dd9110686 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/manifest.json @@ -0,0 +1,165 @@ +{ + "version": "21.0.0", + "artifacts": { + "integ-permissions-boundary-support.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "integ-permissions-boundary-support.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "integ-permissions-boundary-support": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "integ-permissions-boundary-support.template.json", + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/988706b46935e3999d78ec8a91b4db6c7d516d3fb99071d2f2c2c6e0c5dc507e.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "integ-permissions-boundary-support.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "integ-permissions-boundary-support.assets" + ], + "metadata": { + "/integ-permissions-boundary-support/PB/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "PB13A4860B" + } + ], + "/integ-permissions-boundary-support/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/integ-permissions-boundary-support/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "integ-permissions-boundary-support" + }, + "integ-permissions-boundary.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "integ-permissions-boundary.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "integ-permissions-boundary": { + "type": "aws:cloudformation:stack", + "environment": "aws://12345678/test-region", + "properties": { + "templateFile": "integ-permissions-boundary.template.json", + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::12345678:role/cdk-hnb659fds-deploy-role-12345678-test-region", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::12345678:role/cdk-hnb659fds-cfn-exec-role-12345678-test-region", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-12345678-test-region/8e762109b39d7f7170aaa0acabaf6222a2b8f707b4c979de97254aab0338a069.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "integ-permissions-boundary.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::12345678:role/cdk-hnb659fds-lookup-role-12345678-test-region", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "integ-permissions-boundary-support", + "integ-permissions-boundary.assets" + ], + "metadata": { + "/integ-permissions-boundary/TestRole/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "TestRole6C9272DF" + } + ], + "/integ-permissions-boundary/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/integ-permissions-boundary/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "integ-permissions-boundary" + }, + "integtestDefaultTestDeployAssert24D5C536.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "integtestDefaultTestDeployAssert24D5C536.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "integtestDefaultTestDeployAssert24D5C536": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "integtestDefaultTestDeployAssert24D5C536.template.json", + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "integtestDefaultTestDeployAssert24D5C536.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "integtestDefaultTestDeployAssert24D5C536.assets" + ], + "metadata": { + "/integ-test/DefaultTest/DeployAssert/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/integ-test/DefaultTest/DeployAssert/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "integ-test/DefaultTest/DeployAssert" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/tree.json b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/tree.json new file mode 100644 index 0000000000000..3d7bf4a33c584 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/tree.json @@ -0,0 +1,228 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "integ-permissions-boundary-support": { + "id": "integ-permissions-boundary-support", + "path": "integ-permissions-boundary-support", + "children": { + "PB": { + "id": "PB", + "path": "integ-permissions-boundary-support/PB", + "children": { + "ImportedPB": { + "id": "ImportedPB", + "path": "integ-permissions-boundary-support/PB/ImportedPB", + "constructInfo": { + "fqn": "@aws-cdk/core.Resource", + "version": "0.0.0" + } + }, + "Resource": { + "id": "Resource", + "path": "integ-permissions-boundary-support/PB/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::IAM::ManagedPolicy", + "aws:cdk:cloudformation:props": { + "policyDocument": { + "Statement": [ + { + "Action": "*", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "description": "", + "managedPolicyName": { + "Fn::Join": [ + "", + [ + "cdk-hnb659fds-PermissionsBoundary-", + { + "Ref": "AWS::AccountId" + }, + "-", + { + "Ref": "AWS::Region" + } + ] + ] + }, + "path": "/" + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.CfnManagedPolicy", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.ManagedPolicy", + "version": "0.0.0" + } + }, + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "integ-permissions-boundary-support/BootstrapVersion", + "constructInfo": { + "fqn": "@aws-cdk/core.CfnParameter", + "version": "0.0.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "integ-permissions-boundary-support/CheckBootstrapVersion", + "constructInfo": { + "fqn": "@aws-cdk/core.CfnRule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" + } + }, + "integ-permissions-boundary": { + "id": "integ-permissions-boundary", + "path": "integ-permissions-boundary", + "children": { + "TestRole": { + "id": "TestRole", + "path": "integ-permissions-boundary/TestRole", + "children": { + "ImportTestRole": { + "id": "ImportTestRole", + "path": "integ-permissions-boundary/TestRole/ImportTestRole", + "constructInfo": { + "fqn": "@aws-cdk/core.Resource", + "version": "0.0.0" + } + }, + "Resource": { + "id": "Resource", + "path": "integ-permissions-boundary/TestRole/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::IAM::Role", + "aws:cdk:cloudformation:props": { + "assumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "sqs.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.CfnRole", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.Role", + "version": "0.0.0" + } + }, + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "integ-permissions-boundary/BootstrapVersion", + "constructInfo": { + "fqn": "@aws-cdk/core.CfnParameter", + "version": "0.0.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "integ-permissions-boundary/CheckBootstrapVersion", + "constructInfo": { + "fqn": "@aws-cdk/core.CfnRule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" + } + }, + "integ-test": { + "id": "integ-test", + "path": "integ-test", + "children": { + "DefaultTest": { + "id": "DefaultTest", + "path": "integ-test/DefaultTest", + "children": { + "Default": { + "id": "Default", + "path": "integ-test/DefaultTest/Default", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.1.154" + } + }, + "DeployAssert": { + "id": "DeployAssert", + "path": "integ-test/DefaultTest/DeployAssert", + "children": { + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "integ-test/DefaultTest/DeployAssert/BootstrapVersion", + "constructInfo": { + "fqn": "@aws-cdk/core.CfnParameter", + "version": "0.0.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "integ-test/DefaultTest/DeployAssert/CheckBootstrapVersion", + "constructInfo": { + "fqn": "@aws-cdk/core.CfnRule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests.IntegTestCase", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests.IntegTest", + "version": "0.0.0" + } + }, + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.1.154" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.App", + "version": "0.0.0" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.ts b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.ts new file mode 100644 index 0000000000000..c9b36678ef532 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.ts @@ -0,0 +1,33 @@ +import { App, Stack, PermissionsBoundary } from '@aws-cdk/core'; +import { IntegTest } from '@aws-cdk/integ-tests'; +import { Role, ServicePrincipal, ManagedPolicy, PolicyStatement } from '../lib'; + +const app = new App(); + +const supportStack = new Stack(app, 'integ-permissions-boundary-support'); +new ManagedPolicy(supportStack, 'PB', { + statements: [new PolicyStatement({ + actions: ['*'], + resources: ['*'], + })], + managedPolicyName: `cdk-${supportStack.synthesizer.bootstrapQualifier}-PermissionsBoundary-${supportStack.account}-${supportStack.region}`, +}); + +const stack = new Stack(app, 'integ-permissions-boundary', { + env: { + account: process.env.CDK_INTEG_ACCOUNT ?? process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_INTEG_REGION ?? process.env.CDK_DEFAULT_REGION, + + }, + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), +}); +stack.addDependency(supportStack); + +new Role(stack, 'TestRole', { + assumedBy: new ServicePrincipal('sqs.amazonaws.com'), +}); + +new IntegTest(app, 'integ-test', { + testCases: [stack], + enableLookups: true, +}); diff --git a/packages/@aws-cdk/aws-iam/test/role.test.ts b/packages/@aws-cdk/aws-iam/test/role.test.ts index 58519b85cfeef..85e1ee38e6fdb 100644 --- a/packages/@aws-cdk/aws-iam/test/role.test.ts +++ b/packages/@aws-cdk/aws-iam/test/role.test.ts @@ -1,6 +1,6 @@ import { Template, Match, Annotations } from '@aws-cdk/assertions'; import { testDeprecated } from '@aws-cdk/cdk-build-tools'; -import { Duration, Stack, App, CfnResource, RemovalPolicy, Lazy } from '@aws-cdk/core'; +import { Duration, Stack, App, CfnResource, RemovalPolicy, Lazy, Stage, DefaultStackSynthesizer, CliCredentialsStackSynthesizer, PERMISSIONS_BOUNDARY_CONTEXT_KEY, PermissionsBoundary } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { AnyPrincipal, ArnPrincipal, CompositePrincipal, FederatedPrincipal, ManagedPolicy, PolicyStatement, Role, ServicePrincipal, User, Policy, PolicyDocument, Effect } from '../lib'; @@ -908,6 +908,224 @@ describe('IAM role', () => { }); }); +describe('permissions boundary', () => { + test('can be applied to an app', () => { + // GIVEN + const app = new App({ + context: { + [PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { + name: 'cdk-${Qualifier}-PermissionsBoundary', + }, + }, + }); + const stack = new Stack(app); + + // WHEN + new Role(stack, 'Role', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':policy/cdk-hnb659fds-PermissionsBoundary', + ], + ], + }, + }); + }); + + test('can be applied to a stage', () => { + // GIVEN + const app = new App(); + const stage = new Stage(app, 'Stage', { + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary'), + }); + const stack = new Stack(stage); + + // WHEN + new Role(stack, 'Role', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':policy/cdk-hnb659fds-PermissionsBoundary', + ], + ], + }, + }); + }); + + test('can be applied to a stage, and will replace placeholders', () => { + // GIVEN + const app = new App(); + const stage = new Stage(app, 'Stage', { + env: { + region: 'test-region', + account: '123456789012', + }, + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), + }); + const stack = new Stack(stage); + + // WHEN + new Role(stack, 'Role', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::123456789012:policy/cdk-hnb659fds-PermissionsBoundary-123456789012-test-region', + ], + ], + }, + }); + }); + + test('with a custom qualifier', () => { + // GIVEN + const app = new App(); + const stage = new Stage(app, 'Stage', { + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary'), + }); + const stack = new Stack(stage, 'MyStack', { + synthesizer: new DefaultStackSynthesizer({ + qualifier: 'custom', + }), + }); + + // WHEN + new Role(stack, 'Role', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':policy/cdk-custom-PermissionsBoundary', + ], + ], + }, + }); + }); + + test('with a custom permissions boundary', () => { + // GIVEN + const app = new App(); + const stage = new Stage(app, 'Stage', { + permissionsBoundary: PermissionsBoundary.fromName('my-permissions-boundary'), + }); + const stack = new Stack(stage); + + // WHEN + new Role(stack, 'Role', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':policy/my-permissions-boundary', + ], + ], + }, + }); + }); + + test('with a custom permissions boundary and qualifier', () => { + // GIVEN + const app = new App(); + const stage = new Stage(app, 'Stage', { + permissionsBoundary: PermissionsBoundary.fromName('my-${Qualifier}-permissions-boundary'), + }); + const stack = new Stack(stage, 'MyStack', { + synthesizer: new CliCredentialsStackSynthesizer({ + qualifier: 'custom', + }), + }); + + // WHEN + new Role(stack, 'Role', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':policy/my-custom-permissions-boundary', + ], + ], + }, + }); + }); + +}); + test('managed policy ARNs are deduplicated', () => { const app = new App(); const stack = new Stack(app, 'my-stack'); diff --git a/packages/@aws-cdk/core/README.md b/packages/@aws-cdk/core/README.md index b22c9eba3ec01..162b60efac881 100644 --- a/packages/@aws-cdk/core/README.md +++ b/packages/@aws-cdk/core/README.md @@ -1206,4 +1206,23 @@ _~/.cdk.json_ } ``` +## IAM Permissions Boundary + +It is possible to apply an [IAM permissions boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) +to all roles within a specific construct scope. The most common use case would +be to apply a permissions boundary at the `Stage` level. + +```ts +declare const app: App; + +const prodStage = new Stage(app, 'ProdStage', { + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary'), +}); +``` + +Any IAM Roles or Users created within this Stage will have the default +permissions boundary attached. + +For more details see the [Permissions Boundary](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_iam-readme.html#permissions-boundaries) section in the IAM guide. + diff --git a/packages/@aws-cdk/core/lib/index.ts b/packages/@aws-cdk/core/lib/index.ts index c080ef1440851..c3720c49b8a82 100644 --- a/packages/@aws-cdk/core/lib/index.ts +++ b/packages/@aws-cdk/core/lib/index.ts @@ -60,8 +60,9 @@ export * from './cfn-capabilities'; export * from './cloudformation.generated'; export * from './feature-flags'; +export * from './permissions-boundary'; // WARNING: Should not be exported, but currently is because of a bug. See the // class description for more information. export * from './private/intrinsic'; -export * from './names'; \ No newline at end of file +export * from './names'; diff --git a/packages/@aws-cdk/core/lib/permissions-boundary.ts b/packages/@aws-cdk/core/lib/permissions-boundary.ts new file mode 100644 index 0000000000000..a80ae44363862 --- /dev/null +++ b/packages/@aws-cdk/core/lib/permissions-boundary.ts @@ -0,0 +1,109 @@ +import { Construct } from 'constructs'; + +export const PERMISSIONS_BOUNDARY_CONTEXT_KEY = '@aws-cdk/core:permissionsBoundary'; +/** + * Options for binding a Permissions Boundary to a construct scope + */ +export interface PermissionsBoundaryBindOptions {} + +/** + * Apply a permissions boundary to all IAM Roles and Users + * within a specific scope + * + * A permissions boundary is typically applied at the `Stage` scope. + * This allows setting different permissions boundaries per Stage. For + * example, you may _not_ apply a boundary to the `Dev` stage which deploys + * to a personal dev account, but you _do_ apply the default boundary to the + * `Prod` stage. + * + * It is possible to apply different permissions boundaries to different scopes + * within your app. In this case the most specifically applied one wins + * + * @example + * // no permissions boundary for dev stage + * new Stage(app, 'DevStage'); + * + * // default boundary for prod stage + * const prodStage = new Stage(app, 'ProdStage', { + * permissionsBoundary: PermissionsBoundary.fromName('prod-pb'), + * }); + * + * // overriding the pb applied for this stack + * new Stack(prodStage, 'ProdStack1', { + * permissionsBoundary: PermissionsBoundary.fromName('stack-pb'), + * }); + * + * // will inherit the permissions boundary from the stage + * new Stack(prodStage, 'ProdStack2'); + */ +export class PermissionsBoundary { + /** + * Apply a permissions boundary with the given name to all IAM Roles + * and Users created within a scope. + * + * The name can include placeholders for the partition, region, qualifier, and account + * These placeholders will be replaced with the actual values if available. This requires + * that the Stack has the environment specified, it does not work with environment + * agnostic stacks. + * + * - '${AWS::Partition}' + * - '${AWS::Region}' + * - '${AWS::AccountId}' + * - '${Qualifier}' + * + * @param name the name of the permissions boundary policy + * + * @example + * declare const app: App; + * new Stage(app, 'ProdStage', { + * permissionsBoundary: PermissionsBoundary.fromName('my-custom-permissions-boundary'), + * }); + */ + public static fromName(name: string): PermissionsBoundary { + return new PermissionsBoundary(name); + } + + /** + * Apply a permissions boundary with the given ARN to all IAM Roles + * and Users created within a scope. + * + * The arn can include placeholders for the partition, region, qualifier, and account + * These placeholders will be replaced with the actual values if available. This requires + * that the Stack has the environment specified, it does not work with environment + * agnostic stacks. + * + * - '${AWS::Partition}' + * - '${AWS::Region}' + * - '${AWS::AccountId}' + * - '${Qualifier}' + * + * @param arn the ARN of the permissions boundary policy + * + * @example + * declare const app: App; + * new Stage(app, 'ProdStage', { + * permissionsBoundary: PermissionsBoundary.fromArn('arn:aws:iam::${AWS::AccountId}:policy/my-custom-permissions-boundary'), + * }); + */ + public static fromArn(arn: string): PermissionsBoundary { + return new PermissionsBoundary(undefined, arn); + } + + private constructor(private readonly policyName?: string, private readonly policyArn?: string) { + } + + /** + * Apply the permissions boundary to the given scope + * + * Different permissions boundaries can be applied to different scopes + * and the most specific will be applied. + * + * @internal + */ + public _bind(scope: Construct, _options: PermissionsBoundaryBindOptions = {}): void { + scope.node.setContext(PERMISSIONS_BOUNDARY_CONTEXT_KEY, { + name: this.policyName, + arn: this.policyArn, + }); + } +} diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/cli-credentials-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/cli-credentials-synthesizer.ts index 095098ec0022d..7bdef5d8a051f 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/cli-credentials-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/cli-credentials-synthesizer.ts @@ -117,6 +117,10 @@ export class CliCredentialsStackSynthesizer extends StackSynthesizer { } } + public get bootstrapQualifier(): string | undefined { + return this.qualifier; + } + public bind(stack: Stack): void { super.bind(stack); @@ -169,4 +173,4 @@ export class CliCredentialsStackSynthesizer extends StackSynthesizer { additionalDependencies: [assetManifestId], }); } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts index 0118db4190955..109029d05e184 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts @@ -324,6 +324,10 @@ export class DefaultStackSynthesizer extends StackSynthesizer { } } + public get bootstrapQualifier(): string | undefined { + return this.qualifier; + } + public bind(stack: Stack): void { super.bind(stack); diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts index c04867f86695c..bf89868bd0fc6 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts @@ -11,8 +11,10 @@ import { IStackSynthesizer, ISynthesisSession } from './types'; * App builder do not need to use this class directly. */ export class NestedStackSynthesizer extends StackSynthesizer { + public readonly bootstrapQualifier?: string; constructor(private readonly parentDeployment: IStackSynthesizer) { super(); + this.bootstrapQualifier = parentDeployment.bootstrapQualifier; } public addFileAsset(asset: FileAssetSource): FileAssetLocation { diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts index 624d22b173eea..d3cbcbcfc1880 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts @@ -19,6 +19,8 @@ import { IStackSynthesizer, ISynthesisSession } from './types'; * and could not be accessed by external implementors. */ export abstract class StackSynthesizer implements IStackSynthesizer { + public readonly bootstrapQualifier?: string; + private _boundStack?: Stack; /** @@ -354,4 +356,4 @@ function stackLocationOrInstrinsics(stack: Stack) { */ function cfnify(s: string): string { return s.indexOf('${') > -1 ? Fn.sub(s) : s; -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts index d3554c9a8bbc2..18c437da224da 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts @@ -6,6 +6,13 @@ import { Stack } from '../stack'; * Encodes information how a certain Stack should be deployed */ export interface IStackSynthesizer { + /** + * The qualifier used to bootstrap this stack + * + * @default - no qualifier + */ + readonly bootstrapQualifier?: string; + /** * Bind to the stack this environment is going to be used on * diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index f58703bb12ae5..271e700e43939 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -7,6 +7,7 @@ import * as minimatch from 'minimatch'; import { Annotations } from './annotations'; import { App } from './app'; import { Arn, ArnComponents, ArnFormat } from './arn'; +import { Aspects } from './aspect'; import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from './assets'; import { CfnElement } from './cfn-element'; import { Fn } from './cfn-fn'; @@ -15,6 +16,7 @@ import { CfnResource, TagType } from './cfn-resource'; import { ContextProvider } from './context-provider'; import { Environment } from './environment'; import { FeatureFlags } from './feature-flags'; +import { PermissionsBoundary, PERMISSIONS_BOUNDARY_CONTEXT_KEY } from './permissions-boundary'; import { CLOUDFORMATION_TOKEN_RESOLVER, CloudFormationLang } from './private/cloudformation-lang'; import { LogicalIDs } from './private/logical-id'; import { resolve } from './private/resolve'; @@ -151,6 +153,14 @@ export interface StackProps { * @default false */ readonly crossRegionReferences?: boolean; + + /** + * Options for applying a permissions boundary to all IAM Roles + * and Users created within this Stage + * + * @default - no permissions boundary is applied + */ + readonly permissionsBoundary?: PermissionsBoundary; } /** @@ -420,6 +430,82 @@ export class Stack extends Construct implements ITaggable { ? new DefaultStackSynthesizer() : new LegacyStackSynthesizer()); this.synthesizer.bind(this); + + props.permissionsBoundary?._bind(this); + + // add the permissions boundary aspect + this.addPermissionsBoundaryAspect(); + } + + /** + * If a permissions boundary has been applied on this scope or any parent scope + * then this will return the ARN of the permissions boundary. + * + * This will return the permissions boundary that has been applied to the most + * specific scope. + * + * For example: + * + * const stage = new Stage(app, 'stage', { + * permissionsBoundary: PermissionsBoundary.fromName('stage-pb'), + * }); + * + * const stack = new Stack(stage, 'Stack', { + * permissionsBoundary: PermissionsBoundary.fromName('some-other-pb'), + * }); + * + * Stack.permissionsBoundaryArn === 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/some-other-pb'; + * + * @param scope the construct scope to retrieve the permissions boundary name from + * @returns the name of the permissions boundary or undefined if not set + */ + private get permissionsBoundaryArn(): string | undefined { + const qualifier = this.synthesizer.bootstrapQualifier + ?? this.node.tryGetContext(BOOTSTRAP_QUALIFIER_CONTEXT) + ?? DefaultStackSynthesizer.DEFAULT_QUALIFIER; + const spec = new StringSpecializer(this, qualifier); + const context = this.node.tryGetContext(PERMISSIONS_BOUNDARY_CONTEXT_KEY); + let arn: string | undefined; + if (context && context.arn) { + arn = spec.specialize(context.arn); + } else if (context && context.name) { + arn = spec.specialize(this.formatArn({ + service: 'iam', + resource: 'policy', + region: '', + resourceName: context.name, + })); + } + if (arn && + (arn.includes('${Qualifier}') + || arn.includes('${AWS::AccountId}') + || arn.includes('${AWS::Region}') + || arn.includes('${AWS::Partition}'))) { + throw new Error(`The permissions boundary ${arn} includes a pseudo parameter, ` + + 'which is not supported for environment agnostic stacks'); + } + return arn; + } + + /** + * Adds an aspect to the stack that will apply the permissions boundary. + * This will only add the aspect if the permissions boundary has been set + */ + private addPermissionsBoundaryAspect(): void { + const permissionsBoundaryArn = this.permissionsBoundaryArn; + if (permissionsBoundaryArn) { + Aspects.of(this).add({ + visit(node: IConstruct) { + if ( + CfnResource.isCfnResource(node) && + (node.cfnResourceType == 'AWS::IAM::Role' || node.cfnResourceType == 'AWS::IAM::User') + ) { + node.addPropertyOverride('PermissionsBoundary', permissionsBoundaryArn); + } + }, + }); + + } } /** @@ -1410,7 +1496,8 @@ import { FileSystem } from './fs'; import { Names } from './names'; import { Reference } from './reference'; import { IResolvable } from './resolvable'; -import { DefaultStackSynthesizer, IStackSynthesizer, ISynthesisSession, LegacyStackSynthesizer } from './stack-synthesizers'; +import { DefaultStackSynthesizer, IStackSynthesizer, ISynthesisSession, LegacyStackSynthesizer, BOOTSTRAP_QUALIFIER_CONTEXT } from './stack-synthesizers'; +import { StringSpecializer } from './stack-synthesizers/_shared'; import { Stage } from './stage'; import { ITaggable, TagManager } from './tag-manager'; import { Token, Tokenization } from './token'; diff --git a/packages/@aws-cdk/core/lib/stage.ts b/packages/@aws-cdk/core/lib/stage.ts index 64df6a7f96bda..6762a2e2898f0 100644 --- a/packages/@aws-cdk/core/lib/stage.ts +++ b/packages/@aws-cdk/core/lib/stage.ts @@ -1,6 +1,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import { IConstruct, Construct, Node } from 'constructs'; import { Environment } from './environment'; +import { PermissionsBoundary } from './permissions-boundary'; import { synthesize } from './private/synthesis'; const STAGE_SYMBOL = Symbol.for('@aws-cdk/core.Stage'); @@ -60,6 +61,14 @@ export interface StageProps { * @default - Derived from the id. */ readonly stageName?: string; + + /** + * Options for applying a permissions boundary to all IAM Roles + * and Users created within this Stage + * + * @default - no permissions boundary is applied + */ + readonly permissionsBoundary?: PermissionsBoundary; } /** @@ -142,6 +151,9 @@ export class Stage extends Construct { this.region = props.env?.region ?? this.parentStage?.region; this.account = props.env?.account ?? this.parentStage?.account; + + props.permissionsBoundary?._bind(this); + this._assemblyBuilder = this.createBuilder(props.outdir); this.stageName = [this.parentStage?.stageName, props.stageName ?? id].filter(x => x).join('-'); } diff --git a/packages/@aws-cdk/core/test/stack.test.ts b/packages/@aws-cdk/core/test/stack.test.ts index ae1cc4419944b..caba1a0ad9bbe 100644 --- a/packages/@aws-cdk/core/test/stack.test.ts +++ b/packages/@aws-cdk/core/test/stack.test.ts @@ -8,6 +8,10 @@ import { Tags, LegacyStackSynthesizer, DefaultStackSynthesizer, NestedStack, Aws, + PermissionsBoundary, + PERMISSIONS_BOUNDARY_CONTEXT_KEY, + Aspects, + Stage, } from '../lib'; import { Intrinsic } from '../lib/private/intrinsic'; import { resolveReferences } from '../lib/private/refs'; @@ -1622,6 +1626,75 @@ describe('stack', () => { }); }); +describe('permissions boundary', () => { + test('can specify a valid permissions boundary name', () => { + // GIVEN + const app = new App(); + + // WHEN + const stack = new Stack(app, 'Stack', { + permissionsBoundary: PermissionsBoundary.fromName('valid'), + }); + + // THEN + const pbContext = stack.node.tryGetContext(PERMISSIONS_BOUNDARY_CONTEXT_KEY); + expect(pbContext).toEqual({ + name: 'valid', + }); + }); + + test('can specify a valid permissions boundary arn', () => { + // GIVEN + const app = new App(); + + // WHEN + const stack = new Stack(app, 'Stack', { + permissionsBoundary: PermissionsBoundary.fromArn('arn:aws:iam::12345678912:policy/valid'), + }); + + // THEN + const pbContext = stack.node.tryGetContext(PERMISSIONS_BOUNDARY_CONTEXT_KEY); + expect(pbContext).toEqual({ + name: undefined, + arn: 'arn:aws:iam::12345678912:policy/valid', + }); + }); + + test('single aspect is added to stack', () => { + // GIVEN + const app = new App(); + + // WHEN + const stage = new Stage(app, 'Stage', { + permissionsBoundary: PermissionsBoundary.fromArn('arn:aws:iam::12345678912:policy/stage'), + }); + const stack = new Stack(stage, 'Stack', { + permissionsBoundary: PermissionsBoundary.fromArn('arn:aws:iam::12345678912:policy/valid'), + }); + + // THEN + const aspects = Aspects.of(stack).all; + expect(aspects.length).toEqual(1); + const pbContext = stack.node.tryGetContext(PERMISSIONS_BOUNDARY_CONTEXT_KEY); + expect(pbContext).toEqual({ + name: undefined, + arn: 'arn:aws:iam::12345678912:policy/valid', + }); + }); + + test('throws if pseudo parameters are in the name', () => { + // GIVEN + const app = new App(); + + // THEN + expect(() => { + new Stack(app, 'Stack', { + permissionsBoundary: PermissionsBoundary.fromArn('arn:aws:iam::${AWS::AccountId}:policy/valid'), + }); + }).toThrow(/The permissions boundary .* includes a pseudo parameter/); + }); +}); + describe('regionalFact', () => { Fact.register({ name: 'MyFact', region: 'us-east-1', value: 'x.amazonaws.com' }); Fact.register({ name: 'MyFact', region: 'eu-west-1', value: 'x.amazonaws.com' }); diff --git a/packages/aws-cdk-lib/README.md b/packages/aws-cdk-lib/README.md index 92bb8a042acec..da983bbf62ca5 100644 --- a/packages/aws-cdk-lib/README.md +++ b/packages/aws-cdk-lib/README.md @@ -1237,4 +1237,23 @@ _~/.cdk.json_ } ``` +## IAM Permissions Boundary + +It is possible to apply an [IAM permissions boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) +to all roles within a specific construct scope. The most common use case would +be to apply a permissions boundary at the `Stage` level. + +```ts +declare const app: App; + +const prodStage = new Stage(app, 'ProdStage', { + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary'), +}); +``` + +Any IAM Roles or Users created within this Stage will have the default +permissions boundary attached. + +For more details see the [Permissions Boundary](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_iam-readme.html#permissions-boundaries) section in the IAM guide. +