From fcbb102f7e14083b805c634c61cccaf677cb11d6 Mon Sep 17 00:00:00 2001 From: corymhall <43035978+corymhall@users.noreply.github.com> Date: Mon, 14 Nov 2022 19:38:53 +0000 Subject: [PATCH 1/8] feat(iam): apply permissions boundary to a `Stage` This PR should be not be merged until #22792 which adds support for creating a default permissions boundary as part of CDK bootstrap. Once that is merged we will be creating a default permissions boundary managed policy with a standard naming convention. That allows us to easily attach this default permissions boundary to applications at either the `Stage` or `Stack` level. For the implementation, there are two components to "applying" the permissions boundary. The first is "applying" the permissions boundary to a construct scope. For this I've used context since context has built in resolution up the tree. For example, you could theoretically do something like the below. ```ts // apply at the app scope const app = new App({ context: { [PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { name: 'app-level-pb', }, }, }); // apply different value at this stage const prodStage = new Stage(app, 'Stage', { permissionsBoundary: PermissionsBoundary.default(), }); const stack = new Stack(prodStage, 'Stack', { permissionsBoundary: PermissionsBoundary.fromName('stack-level-pb'), }); class MyConstruct extends Construct { constructor(scope: Construct, id: string) { super(scope, id); const pb = PermissionsBoundary.fromName('construct-level-pb'); pb.bind(this); } } ``` While the above is possible, the most likely scenario is that you would apply a permission boundary at the `Stage` or `Stack` level. Stages/Stacks correspond to AWS environments (account + region) and so they would most likely also map to bootstrap environments. Because of this I've added parameters a new `permissionsBoundary` property to both the `Stage` & `Stack` props to make it easier to configure. The second aspect to "applying" the boundary is actually attaching the permissions boundary to each IAM Role & User that is created. For this I add an Aspect by default to every `Stack`. This aspect will look for the `context` that was applied earlier for whether or not it should attach a permissions boundary to the Role or User. closes #22745 --- packages/@aws-cdk/aws-iam/README.md | 48 +++++ .../aws-iam/rosetta/default.ts-fixture | 2 +- packages/@aws-cdk/aws-iam/test/role.test.ts | 186 +++++++++++++++++- packages/@aws-cdk/core/README.md | 41 ++++ packages/@aws-cdk/core/lib/index.ts | 3 +- .../@aws-cdk/core/lib/permissions-boundary.ts | 161 +++++++++++++++ .../cli-credentials-synthesizer.ts | 6 +- .../stack-synthesizers/default-synthesizer.ts | 9 + .../core/lib/stack-synthesizers/nested.ts | 2 + .../stack-synthesizers/stack-synthesizer.ts | 4 +- .../core/lib/stack-synthesizers/types.ts | 7 + packages/@aws-cdk/core/lib/stack.ts | 38 +++- packages/@aws-cdk/core/lib/stage.ts | 12 ++ packages/aws-cdk-lib/README.md | 41 ++++ 14 files changed, 554 insertions(+), 6 deletions(-) create mode 100644 packages/@aws-cdk/core/lib/permissions-boundary.ts diff --git a/packages/@aws-cdk/aws-iam/README.md b/packages/@aws-cdk/aws-iam/README.md index 73f55df549756..8a68d8dc79c3d 100644 --- a/packages/@aws-cdk/aws-iam/README.md +++ b/packages/@aws-cdk/aws-iam/README.md @@ -295,6 +295,54 @@ 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 the default permissions boundary has been created as part of CDK boostrap, it +is possible to apply this permissions boundary at the `Stage` or `Stack` scope. + +```ts +declare const app: App; + +const prodStage = new Stage(app, 'ProdStage', { + permissionsBoundary: PermissionsBoundary.default(), +}); +``` + +This will apply the default permissions boundary created as part of CDK +bootstrap to all IAM Roles that are created within this `Stage`. + +If you have created a permissions boundary with a custom name, it is also +possible to specify the custom name. + +```ts +declare const app: App; + +const prodStage = new Stage(app, 'ProdStage', { + permissionsBoundary: PermissionsBoundary.fromName('my-custom-pb-name'), +}); +``` + +Similar to the default permissions boundary, the `fromName` method will also +replace the `${Qualifier}` string with the Stack synthesizer qualifier. For +example if you are using a custom stack synthesizer with a custom qualifier +which is part of the permissions boundary policy name. + +```ts +declare const app: App; + +const prodStage = new Stage(app, 'ProdStage', { + permissionsBoundary: PermissionsBoundary.fromName('my-${Qualifier}-pb-name'), +}); + +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/rosetta/default.ts-fixture b/packages/@aws-cdk/aws-iam/rosetta/default.ts-fixture index a27f557ccf250..edafe485269d8 100644 --- a/packages/@aws-cdk/aws-iam/rosetta/default.ts-fixture +++ b/packages/@aws-cdk/aws-iam/rosetta/default.ts-fixture @@ -1,5 +1,5 @@ import { Construct } from 'constructs'; -import { CustomResource, Stack } from '@aws-cdk/core'; +import { CustomResource, Stack, App, DefaultStackSynthesizer, Stage, PermissionsBoundary } from '@aws-cdk/core'; import * as codepipeline from '@aws-cdk/aws-codepipeline'; import * as dynamodb from '@aws-cdk/aws-dynamodb'; import * as lambda from '@aws-cdk/aws-lambda'; diff --git a/packages/@aws-cdk/aws-iam/test/role.test.ts b/packages/@aws-cdk/aws-iam/test/role.test.ts index 7651fea51e403..bfbbb7a5d7c17 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 } from '@aws-cdk/assertions'; import { testDeprecated } from '@aws-cdk/cdk-build-tools'; -import { Duration, Stack, App, CfnResource } from '@aws-cdk/core'; +import { Duration, Stack, App, CfnResource, Stage, DefaultStackSynthesizer, CliCredentialsStackSynthesizer, PERMISSIONS_BOUNDARY_CONTEXT_KEY, PermissionsBoundary } from '@aws-cdk/core'; import { AnyPrincipal, ArnPrincipal, CompositePrincipal, FederatedPrincipal, ManagedPolicy, PolicyStatement, Role, ServicePrincipal, User, Policy, PolicyDocument } from '../lib'; describe('IAM role', () => { @@ -633,6 +633,190 @@ describe('IAM role', () => { }); }); +describe('permissions boundary', () => { + test('can be applied to an app', () => { + // GIVEN + const app = new App({ + context: { + [PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { + name: DefaultStackSynthesizer.DEFAULT_PERMISSIONS_BOUNDARY_NAME, + }, + }, + }); + 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-${AWS::AccountId}-${AWS::Region}', + ], + ], + }, + }); + }); + + test('can be applied to a stage', () => { + // GIVEN + const app = new App(); + const stage = new Stage(app, 'Stage', { + permissionsBoundary: PermissionsBoundary.default(), + }); + 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-${AWS::AccountId}-${AWS::Region}', + ], + ], + }, + }); + }); + + test('with a custom qualifier', () => { + // GIVEN + const app = new App(); + const stage = new Stage(app, 'Stage', { + permissionsBoundary: PermissionsBoundary.default(), + }); + 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-${AWS::AccountId}-${AWS::Region}', + ], + ], + }, + }); + }); + + 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..a0805dd8a60db 100644 --- a/packages/@aws-cdk/core/README.md +++ b/packages/@aws-cdk/core/README.md @@ -1206,4 +1206,45 @@ _~/.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.default(), +}); +``` + +Any IAM Roles or Users created within this Stage will have the default +permissions boundary attached. + + It is possible to apply different permissions boundaries to different scopes + within your app. In this case the most specifically applied one wins. + +```ts +declare const app: App; + +// no boundary +new Stage(app, 'DevStage'); + +const prodStage = new Stage(app, 'ProdStage', { + permissionsBoundary: PermissionsBoundary.default(), +}); + +// overriding the pb applied for this stack +new Stack(prodStage, 'ProdStack1', { + permissionsBoundary: PermissionsBoundary.fromName('custom-pb'), +}); + +// will inherit the default permissions boundary from the stage +new Stack(prodStage, 'ProdStack2'); +``` + +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..2e51d5094c680 --- /dev/null +++ b/packages/@aws-cdk/core/lib/permissions-boundary.ts @@ -0,0 +1,161 @@ +import { Construct } from 'constructs'; +import { Arn } from './arn'; +import { Stack } from './stack'; +import { DefaultStackSynthesizer } from './stack-synthesizers'; +import { Token } from './token'; + +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 + */ +export abstract class PermissionsBoundary { + /** + * The default bootstrap permission boundary + * {@link DefaultStackSynthesizer.DEFAULT_PERMISSIONS_BOUNDARY_NAME} will be applied + * to all IAM Roles and Users within the given scope + * + * @example + * declare const app: App; + * new Stage(app, 'ProdStage', { + * permissionsBoundary: PermissionsBoundary.default(), + * }); + */ + public static default(): PermissionsBoundary { + return new PermissionsBoundaryManager(DefaultStackSynthesizer.DEFAULT_PERMISSIONS_BOUNDARY_NAME); + } + + /** + * Apply a permissions boundary with the given name to all IAM Roles + * and Users created within a scope. The name can include the '${Qualifier}' string + * which will be replaced by the stack 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 PermissionsBoundaryManager(name); + } + + /** + * Apply a permissions boundary with the given ARN to all IAM Roles + * and Users created within a scope. The ARN can include the '${Qualifier}' string + * which will be replaced by the stack qualifier. + h + * @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 { + let name; + if (!Token.isUnresolved(arn)) { + name = Arn.parse(arn); + } + return new PermissionsBoundaryManager(name?.resourceName, arn); + } + + /** + * Apply the permission boundary to the given scope + * + * Different permission boundaries can be applied to different scopes + * and the most specific will be applied. + */ + public abstract bind(scope: Construct, options?: PermissionsBoundaryBindOptions): void; +} + +/** + * 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.default(), + * }); + * + * // overriding the pb applied for this stack + * new Stack(prodStage, 'ProdStack1', { + * permissionsBoundary: PermissionsBoundary.fromName('custom-pb'), + * }); + * + * // will inherit the permissions boundary from the stage + * new Stack(prodStage, 'ProdStack2'); + */ +export class PermissionsBoundaryManager extends PermissionsBoundary { + /** + * If a permissions boundary has been applied on a scope (includes all parent scopes) + * 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. + * + * @example + * declare const app: App + * const stage = new Stage(app, 'stage', { + * permissionsBoundary: PermissionsBoundary.default(), + * }); + * + * const stack = new Stack(stage, 'Stack', { + * permissionsBoundary: PermissionsBoundary.fromName('some-other-pb'), + * }); + * + * PermissionsBoundary.arn(stack) === 'arn:aws: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 + */ + public static arn(scope: Construct): string | undefined { + const context = scope.node.tryGetContext(PERMISSIONS_BOUNDARY_CONTEXT_KEY); + if (context && typeof context === 'object') { + if (context.hasOwnProperty('arn') && context.arn) { + return context.arn; + } else if (context.hasOwnProperty('name') && context.name) { + return Stack.of(scope).formatArn({ + service: 'iam', + region: '', + resource: 'policy', + resourceName: context.name, + }); + } + } + return; + } + + + constructor(private readonly policyName?: string, private readonly policyArn?: string) { + super(); + } + + 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..403aba4a8758b 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts @@ -273,6 +273,11 @@ export class DefaultStackSynthesizer extends StackSynthesizer { */ public static readonly DEFAULT_FILE_ASSET_KEY_ARN_EXPORT_NAME = 'CdkBootstrap-${Qualifier}-FileAssetKeyArn'; + /** + * Default name of the permissions boundary managed policy + */ + public static readonly DEFAULT_PERMISSIONS_BOUNDARY_NAME = 'cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'; + /** * Default file asset prefix */ @@ -324,6 +329,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..3dcc475a2de21 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 for 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..4fef8c21b6d0e 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 { PermissionsBoundaryManager, PermissionsBoundary } 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,31 @@ export class Stack extends Construct implements ITaggable { ? new DefaultStackSynthesizer() : new LegacyStackSynthesizer()); this.synthesizer.bind(this); + + props.permissionsBoundary?.bind(this); + // add the permission boundary aspect + this.addPermissionsBoundaryAspect(); + } + + private addPermissionsBoundaryAspect(): void { + Aspects.of(this).add({ + visit(node: IConstruct) { + if ( + CfnResource.isCfnResource(node) && + (node.cfnResourceType == 'AWS::IAM::Role' || node.cfnResourceType == 'AWS::IAM::User') + ) { + const permissionsBoundaryArn = PermissionsBoundaryManager.arn(node); + if (permissionsBoundaryArn) { + const stack = Stack.of(node); + const qualifier = stack.synthesizer.bootstrapQualifier + ?? node.node.tryGetContext(BOOTSTRAP_QUALIFIER_CONTEXT) + ?? DefaultStackSynthesizer.DEFAULT_QUALIFIER; + const spec = new StringSpecializer(stack, qualifier); + node.addPropertyOverride('PermissionsBoundary', spec.specialize(permissionsBoundaryArn)); + } + } + }, + }); } /** @@ -1410,7 +1445,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..d353fff9395ec 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-lib/README.md b/packages/aws-cdk-lib/README.md index 92bb8a042acec..110e24a06237f 100644 --- a/packages/aws-cdk-lib/README.md +++ b/packages/aws-cdk-lib/README.md @@ -1237,4 +1237,45 @@ _~/.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.default(), +}); +``` + +Any IAM Roles or Users created within this Stage will have the default +permissions boundary attached. + + It is possible to apply different permissions boundaries to different scopes + within your app. In this case the most specifically applied one wins. + +```ts +declare const app: App; + +// no boundary +new Stage(app, 'DevStage'); + +const prodStage = new Stage(app, 'ProdStage', { + permissionsBoundary: PermissionsBoundary.default(), +}); + +// overriding the pb applied for this stack +new Stack(prodStage, 'ProdStack1', { + permissionsBoundary: PermissionsBoundary.fromName('custom-pb'), +}); + +// will inherit the default permissions boundary from the stage +new Stack(prodStage, 'ProdStack2'); +``` + +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. + From beae65123b1cdea6ff4979806b7542ebef8da631 Mon Sep 17 00:00:00 2001 From: corymhall <43035978+corymhall@users.noreply.github.com> Date: Mon, 14 Nov 2022 20:17:53 +0000 Subject: [PATCH 2/8] adding integration test --- .../cdk.out | 1 + .../integ-permissions-boundary.assets.json | 19 +++ .../integ-permissions-boundary.template.json | 67 ++++++++ .../integ.json | 12 ++ ...efaultTestDeployAssert24D5C536.assets.json | 19 +++ ...aultTestDeployAssert24D5C536.template.json | 36 +++++ .../manifest.json | 111 ++++++++++++++ .../tree.json | 144 ++++++++++++++++++ .../test/integ.permissions-boundary.ts | 17 +++ 9 files changed, 426 insertions(+) create mode 100644 packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/cdk.out create mode 100644 packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary.assets.json create mode 100644 packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary.template.json create mode 100644 packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ.json create mode 100644 packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integtestDefaultTestDeployAssert24D5C536.assets.json create mode 100644 packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integtestDefaultTestDeployAssert24D5C536.template.json create mode 100644 packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/manifest.json create mode 100644 packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/tree.json create mode 100644 packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.ts 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.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..b64f5a901a0eb --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary.assets.json @@ -0,0 +1,19 @@ +{ + "version": "21.0.0", + "files": { + "e44b37dd46a689c029230d5e93d15c50a378ef25ec3b354d2ffcd3caa38adc6c": { + "source": { + "path": "integ-permissions-boundary.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "e44b37dd46a689c029230d5e93d15c50a378ef25ec3b354d2ffcd3caa38adc6c.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.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..c7824bad86a04 --- /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::aws:policy/AdministratorAccess" + ] + ] + } + } + } + }, + "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..4af82ec5a3a3b --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ.json @@ -0,0 +1,12 @@ +{ + "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..550276a13e0ff --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/manifest.json @@ -0,0 +1,111 @@ +{ + "version": "21.0.0", + "artifacts": { + "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://unknown-account/unknown-region", + "properties": { + "templateFile": "integ-permissions-boundary.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}/e44b37dd46a689c029230d5e93d15c50a378ef25ec3b354d2ffcd3caa38adc6c.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "integ-permissions-boundary.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.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..f3fe83556ed90 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/tree.json @@ -0,0 +1,144 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "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..794aa2c96ce94 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.ts @@ -0,0 +1,17 @@ +import { App, Stack, PermissionsBoundary } from '@aws-cdk/core'; +import { IntegTest } from '@aws-cdk/integ-tests'; +import { Role, ServicePrincipal, ManagedPolicy } from '../lib'; + +const app = new App(); + +const stack = new Stack(app, 'integ-permissions-boundary', { + permissionsBoundary: PermissionsBoundary.fromArn(ManagedPolicy.fromAwsManagedPolicyName('AdministratorAccess').managedPolicyArn), +}); + +new Role(stack, 'TestRole', { + assumedBy: new ServicePrincipal('sqs.amazonaws.com'), +}); + +new IntegTest(app, 'integ-test', { + testCases: [stack], +}); From e19652523adcea041163a4268d31165c39a5a666 Mon Sep 17 00:00:00 2001 From: corymhall <43035978+corymhall@users.noreply.github.com> Date: Mon, 14 Nov 2022 20:18:09 +0000 Subject: [PATCH 3/8] update package.json --- packages/@aws-cdk/aws-iam/package.json | 1 + 1 file changed, 1 insertion(+) 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", From fe4f478347c87d116eee7046ae1db4e8a1477b6f Mon Sep 17 00:00:00 2001 From: corymhall <43035978+corymhall@users.noreply.github.com> Date: Tue, 15 Nov 2022 21:10:59 +0000 Subject: [PATCH 4/8] refactoring some --- packages/@aws-cdk/aws-iam/README.md | 75 +++++++-- .../aws-iam/rosetta/default.ts-fixture | 10 +- packages/@aws-cdk/aws-iam/test/role.test.ts | 110 ++++-------- packages/@aws-cdk/core/README.md | 24 +-- .../@aws-cdk/core/lib/permissions-boundary.ts | 156 ++++++------------ packages/@aws-cdk/core/lib/stack.ts | 71 ++++++-- packages/@aws-cdk/core/lib/stage.ts | 2 +- packages/aws-cdk-lib/README.md | 24 +-- 8 files changed, 210 insertions(+), 262 deletions(-) diff --git a/packages/@aws-cdk/aws-iam/README.md b/packages/@aws-cdk/aws-iam/README.md index 74863f07c1ef8..a364c656ba75b 100644 --- a/packages/@aws-cdk/aws-iam/README.md +++ b/packages/@aws-cdk/aws-iam/README.md @@ -418,41 +418,82 @@ IAM policies. ### Bootstrap Permissions Boundary -If the default permissions boundary has been created as part of CDK boostrap, it -is possible to apply this permissions boundary at the `Stage` or `Stack` scope. +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. -```ts -declare const app: App; +For example if your organization has created and is enforcing a permissions +boundary with the name +`cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}` -const prodStage = new Stage(app, 'ProdStage', { - permissionsBoundary: PermissionsBoundary.default(), +```json +{ + "context": { + "@aws-cdk/core:permissionsBoundary": { + "name": "cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}" + } + } +} +``` + +OR + +```ts +new App({ + context: { + [PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { + name: 'cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + }, + }, }); ``` -This will apply the default permissions boundary created as part of CDK -bootstrap to all IAM Roles that are created within this `Stage`. +Another scenario might be if your organization enforces different permissions +boundaries for different environments. For example your CDK application may have -If you have created a permissions boundary with a custom name, it is also -possible to specify the custom name. +* `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; -const prodStage = new Stage(app, 'ProdStage', { - permissionsBoundary: PermissionsBoundary.fromName('my-custom-pb-name'), +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'), }); ``` -Similar to the default permissions boundary, the `fromName` method will also -replace the `${Qualifier}` string with the Stack synthesizer qualifier. For -example if you are using a custom stack synthesizer with a custom qualifier -which is part of the permissions boundary policy name. +The provided name can include placeholders for the partition, region, qualifier, and account +These placeholders will be replaced with the actual values if available. + +* '${AWS::Partition}' +* '${AWS::Region}' +* '${AWS::AccountId}' +* '${Qualifier}' + ```ts declare const app: App; const prodStage = new Stage(app, 'ProdStage', { - permissionsBoundary: PermissionsBoundary.fromName('my-${Qualifier}-pb-name'), + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), }); new Stack(prodStage, 'ProdStack', { diff --git a/packages/@aws-cdk/aws-iam/rosetta/default.ts-fixture b/packages/@aws-cdk/aws-iam/rosetta/default.ts-fixture index cdd8fa18f180b..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, DefaultStackSynthesizer, Stage, PermissionsBoundary } 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/role.test.ts b/packages/@aws-cdk/aws-iam/test/role.test.ts index 09cd45dc20411..2ccdcf71c4ca2 100644 --- a/packages/@aws-cdk/aws-iam/test/role.test.ts +++ b/packages/@aws-cdk/aws-iam/test/role.test.ts @@ -927,22 +927,7 @@ describe('permissions boundary', () => { // THEN Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { - PermissionsBoundary: { - 'Fn::Join': [ - '', - [ - 'arn:', - { - Ref: 'AWS::Partition', - }, - ':iam::', - { - Ref: 'AWS::AccountId', - }, - ':policy/cdk-hnb659fds-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', - ], - ], - }, + PermissionsBoundary: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-hnb659fds-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', }); }); @@ -950,7 +935,7 @@ describe('permissions boundary', () => { // GIVEN const app = new App(); const stage = new Stage(app, 'Stage', { - permissionsBoundary: PermissionsBoundary.default(), + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), }); const stack = new Stack(stage); @@ -961,22 +946,30 @@ describe('permissions boundary', () => { // THEN Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { - PermissionsBoundary: { - 'Fn::Join': [ - '', - [ - 'arn:', - { - Ref: 'AWS::Partition', - }, - ':iam::', - { - Ref: 'AWS::AccountId', - }, - ':policy/cdk-hnb659fds-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', - ], - ], + PermissionsBoundary: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-hnb659fds-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + }); + }); + + 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: 'arn:${AWS::Partition}:iam::123456789012:policy/cdk-hnb659fds-PermissionsBoundary-123456789012-test-region', }); }); @@ -984,7 +977,7 @@ describe('permissions boundary', () => { // GIVEN const app = new App(); const stage = new Stage(app, 'Stage', { - permissionsBoundary: PermissionsBoundary.default(), + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), }); const stack = new Stack(stage, 'MyStack', { synthesizer: new DefaultStackSynthesizer({ @@ -999,22 +992,7 @@ describe('permissions boundary', () => { // THEN Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { - PermissionsBoundary: { - 'Fn::Join': [ - '', - [ - 'arn:', - { - Ref: 'AWS::Partition', - }, - ':iam::', - { - Ref: 'AWS::AccountId', - }, - ':policy/cdk-custom-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', - ], - ], - }, + PermissionsBoundary: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-custom-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', }); }); @@ -1033,22 +1011,7 @@ describe('permissions boundary', () => { // THEN Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { - PermissionsBoundary: { - 'Fn::Join': [ - '', - [ - 'arn:', - { - Ref: 'AWS::Partition', - }, - ':iam::', - { - Ref: 'AWS::AccountId', - }, - ':policy/my-permissions-boundary', - ], - ], - }, + PermissionsBoundary: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/my-permissions-boundary', }); }); @@ -1071,22 +1034,7 @@ describe('permissions boundary', () => { // THEN Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { - PermissionsBoundary: { - 'Fn::Join': [ - '', - [ - 'arn:', - { - Ref: 'AWS::Partition', - }, - ':iam::', - { - Ref: 'AWS::AccountId', - }, - ':policy/my-custom-permissions-boundary', - ], - ], - }, + PermissionsBoundary: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/my-custom-permissions-boundary', }); }); diff --git a/packages/@aws-cdk/core/README.md b/packages/@aws-cdk/core/README.md index a0805dd8a60db..7718ddc27c724 100644 --- a/packages/@aws-cdk/core/README.md +++ b/packages/@aws-cdk/core/README.md @@ -1216,35 +1216,13 @@ be to apply a permissions boundary at the `Stage` level. declare const app: App; const prodStage = new Stage(app, 'ProdStage', { - permissionsBoundary: PermissionsBoundary.default(), + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), }); ``` Any IAM Roles or Users created within this Stage will have the default permissions boundary attached. - It is possible to apply different permissions boundaries to different scopes - within your app. In this case the most specifically applied one wins. - -```ts -declare const app: App; - -// no boundary -new Stage(app, 'DevStage'); - -const prodStage = new Stage(app, 'ProdStage', { - permissionsBoundary: PermissionsBoundary.default(), -}); - -// overriding the pb applied for this stack -new Stack(prodStage, 'ProdStack1', { - permissionsBoundary: PermissionsBoundary.fromName('custom-pb'), -}); - -// will inherit the default permissions boundary from the stage -new Stack(prodStage, 'ProdStack2'); -``` - 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/permissions-boundary.ts b/packages/@aws-cdk/core/lib/permissions-boundary.ts index 2e51d5094c680..c72d85a498a3d 100644 --- a/packages/@aws-cdk/core/lib/permissions-boundary.ts +++ b/packages/@aws-cdk/core/lib/permissions-boundary.ts @@ -1,7 +1,5 @@ import { Construct } from 'constructs'; import { Arn } from './arn'; -import { Stack } from './stack'; -import { DefaultStackSynthesizer } from './stack-synthesizers'; import { Token } from './token'; export const PERMISSIONS_BOUNDARY_CONTEXT_KEY = '@aws-cdk/core:permissionsBoundary'; @@ -10,73 +8,6 @@ export const PERMISSIONS_BOUNDARY_CONTEXT_KEY = '@aws-cdk/core:permissionsBounda */ export interface PermissionsBoundaryBindOptions {} -/** - * Apply a permissions boundary to all IAM Roles and Users - * within a specific scope - */ -export abstract class PermissionsBoundary { - /** - * The default bootstrap permission boundary - * {@link DefaultStackSynthesizer.DEFAULT_PERMISSIONS_BOUNDARY_NAME} will be applied - * to all IAM Roles and Users within the given scope - * - * @example - * declare const app: App; - * new Stage(app, 'ProdStage', { - * permissionsBoundary: PermissionsBoundary.default(), - * }); - */ - public static default(): PermissionsBoundary { - return new PermissionsBoundaryManager(DefaultStackSynthesizer.DEFAULT_PERMISSIONS_BOUNDARY_NAME); - } - - /** - * Apply a permissions boundary with the given name to all IAM Roles - * and Users created within a scope. The name can include the '${Qualifier}' string - * which will be replaced by the stack 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 PermissionsBoundaryManager(name); - } - - /** - * Apply a permissions boundary with the given ARN to all IAM Roles - * and Users created within a scope. The ARN can include the '${Qualifier}' string - * which will be replaced by the stack qualifier. - h - * @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 { - let name; - if (!Token.isUnresolved(arn)) { - name = Arn.parse(arn); - } - return new PermissionsBoundaryManager(name?.resourceName, arn); - } - - /** - * Apply the permission boundary to the given scope - * - * Different permission boundaries can be applied to different scopes - * and the most specific will be applied. - */ - public abstract bind(scope: Construct, options?: PermissionsBoundaryBindOptions): void; -} - /** * Apply a permissions boundary to all IAM Roles and Users * within a specific scope @@ -96,63 +27,82 @@ export abstract class PermissionsBoundary { * * // default boundary for prod stage * const prodStage = new Stage(app, 'ProdStage', { - * permissionsBoundary: PermissionsBoundary.default(), + * permissionsBoundary: PermissionsBoundary.fromName('prod-pb'), * }); * * // overriding the pb applied for this stack * new Stack(prodStage, 'ProdStack1', { - * permissionsBoundary: PermissionsBoundary.fromName('custom-pb'), + * permissionsBoundary: PermissionsBoundary.fromName('stack-pb'), * }); * * // will inherit the permissions boundary from the stage * new Stack(prodStage, 'ProdStack2'); */ -export class PermissionsBoundaryManager extends PermissionsBoundary { +export class PermissionsBoundary { /** - * If a permissions boundary has been applied on a scope (includes all parent scopes) - * then this will return the ARN of the permissions boundary. + * 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 will return the permissions boundary that has been applied to the most - * specific scope. + * - '${AWS::Partition}' + * - '${AWS::Region}' + * - '${AWS::AccountId}' + * - '${Qualifier}' + * + * @param name the name of the permissions boundary policy * * @example - * declare const app: App - * const stage = new Stage(app, 'stage', { - * permissionsBoundary: PermissionsBoundary.default(), + * 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. * - * const stack = new Stack(stage, 'Stack', { - * permissionsBoundary: PermissionsBoundary.fromName('some-other-pb'), - * }); + * The arn can include placeholders for the partition, region, qualifier, and account + * These placeholders will be replaced with the actual values if available. + * + * - '${AWS::Partition}' + * - '${AWS::Region}' + * - '${AWS::AccountId}' + * - '${Qualifier}' * - * PermissionsBoundary.arn(stack) === 'arn:aws:iam::${AWS::AccountId}:policy/some-other-pb'; + * @param arn the ARN of the permissions boundary policy * - * @param scope the construct scope to retrieve the permissions boundary name from - * @returns the name of the permissions boundary or undefined if not set + * @example + * declare const app: App; + * new Stage(app, 'ProdStage', { + * permissionsBoundary: PermissionsBoundary.fromArn('arn:aws:iam::${AWS::AccountId}:policy/my-custom-permissions-boundary'), + * }); */ - public static arn(scope: Construct): string | undefined { - const context = scope.node.tryGetContext(PERMISSIONS_BOUNDARY_CONTEXT_KEY); - if (context && typeof context === 'object') { - if (context.hasOwnProperty('arn') && context.arn) { - return context.arn; - } else if (context.hasOwnProperty('name') && context.name) { - return Stack.of(scope).formatArn({ - service: 'iam', - region: '', - resource: 'policy', - resourceName: context.name, - }); - } + public static fromArn(arn: string): PermissionsBoundary { + let name; + if (!Token.isUnresolved(arn)) { + name = Arn.parse(arn); } - return; + return new PermissionsBoundary(name?.resourceName, arn); } - - constructor(private readonly policyName?: string, private readonly policyArn?: string) { - super(); + private constructor(private readonly policyName?: string, private readonly policyArn?: string) { } - public bind(scope: Construct, _options: PermissionsBoundaryBindOptions = {}): void { + /** + * Apply the permission boundary to the given scope + * + * Different permission 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.ts b/packages/@aws-cdk/core/lib/stack.ts index 4fef8c21b6d0e..18a20811474c4 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -16,7 +16,7 @@ import { CfnResource, TagType } from './cfn-resource'; import { ContextProvider } from './context-provider'; import { Environment } from './environment'; import { FeatureFlags } from './feature-flags'; -import { PermissionsBoundaryManager, PermissionsBoundary } from './permissions-boundary'; +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'; @@ -431,20 +431,64 @@ export class Stack extends Construct implements ITaggable { : new LegacyStackSynthesizer()); this.synthesizer.bind(this); - props.permissionsBoundary?.bind(this); + props.permissionsBoundary?._bind(this); + // add the permission 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 context = this.node.tryGetContext(PERMISSIONS_BOUNDARY_CONTEXT_KEY); + if (context && context.arn) { + return context.arn; + } else if (context && context.name) { + return Arn.format({ + service: 'iam', + resource: 'policy', + region: '', + account: '${AWS::AccountId}', + partition: '${AWS::Partition}', + resourceName: context.name, + }); + } + return; + } + + /** + * 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 { - Aspects.of(this).add({ - visit(node: IConstruct) { - if ( - CfnResource.isCfnResource(node) && - (node.cfnResourceType == 'AWS::IAM::Role' || node.cfnResourceType == 'AWS::IAM::User') - ) { - const permissionsBoundaryArn = PermissionsBoundaryManager.arn(node); - if (permissionsBoundaryArn) { + 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') + ) { const stack = Stack.of(node); const qualifier = stack.synthesizer.bootstrapQualifier ?? node.node.tryGetContext(BOOTSTRAP_QUALIFIER_CONTEXT) @@ -452,9 +496,10 @@ export class Stack extends Construct implements ITaggable { const spec = new StringSpecializer(stack, qualifier); node.addPropertyOverride('PermissionsBoundary', spec.specialize(permissionsBoundaryArn)); } - } - }, - }); + }, + }); + + } } /** diff --git a/packages/@aws-cdk/core/lib/stage.ts b/packages/@aws-cdk/core/lib/stage.ts index d353fff9395ec..6762a2e2898f0 100644 --- a/packages/@aws-cdk/core/lib/stage.ts +++ b/packages/@aws-cdk/core/lib/stage.ts @@ -152,7 +152,7 @@ export class Stage extends Construct { this.account = props.env?.account ?? this.parentStage?.account; - props.permissionsBoundary?.bind(this); + 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-lib/README.md b/packages/aws-cdk-lib/README.md index 110e24a06237f..785ffea34721e 100644 --- a/packages/aws-cdk-lib/README.md +++ b/packages/aws-cdk-lib/README.md @@ -1247,35 +1247,13 @@ be to apply a permissions boundary at the `Stage` level. declare const app: App; const prodStage = new Stage(app, 'ProdStage', { - permissionsBoundary: PermissionsBoundary.default(), + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), }); ``` Any IAM Roles or Users created within this Stage will have the default permissions boundary attached. - It is possible to apply different permissions boundaries to different scopes - within your app. In this case the most specifically applied one wins. - -```ts -declare const app: App; - -// no boundary -new Stage(app, 'DevStage'); - -const prodStage = new Stage(app, 'ProdStage', { - permissionsBoundary: PermissionsBoundary.default(), -}); - -// overriding the pb applied for this stack -new Stack(prodStage, 'ProdStack1', { - permissionsBoundary: PermissionsBoundary.fromName('custom-pb'), -}); - -// will inherit the default permissions boundary from the stage -new Stack(prodStage, 'ProdStack2'); -``` - 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. From b616a6c07a74002d03bb7618274860c5cff629ff Mon Sep 17 00:00:00 2001 From: corymhall <43035978+corymhall@users.noreply.github.com> Date: Tue, 15 Nov 2022 21:19:20 +0000 Subject: [PATCH 5/8] minor updates --- packages/@aws-cdk/core/lib/permissions-boundary.ts | 4 ++-- .../core/lib/stack-synthesizers/default-synthesizer.ts | 5 ----- packages/@aws-cdk/core/lib/stack-synthesizers/types.ts | 2 +- packages/@aws-cdk/core/lib/stack.ts | 2 +- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/@aws-cdk/core/lib/permissions-boundary.ts b/packages/@aws-cdk/core/lib/permissions-boundary.ts index c72d85a498a3d..6f563879bf72f 100644 --- a/packages/@aws-cdk/core/lib/permissions-boundary.ts +++ b/packages/@aws-cdk/core/lib/permissions-boundary.ts @@ -95,9 +95,9 @@ export class PermissionsBoundary { } /** - * Apply the permission boundary to the given scope + * Apply the permissions boundary to the given scope * - * Different permission boundaries can be applied to different scopes + * Different permissions boundaries can be applied to different scopes * and the most specific will be applied. * * @internal 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 403aba4a8758b..109029d05e184 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts @@ -273,11 +273,6 @@ export class DefaultStackSynthesizer extends StackSynthesizer { */ public static readonly DEFAULT_FILE_ASSET_KEY_ARN_EXPORT_NAME = 'CdkBootstrap-${Qualifier}-FileAssetKeyArn'; - /** - * Default name of the permissions boundary managed policy - */ - public static readonly DEFAULT_PERMISSIONS_BOUNDARY_NAME = 'cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'; - /** * Default file asset prefix */ diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts index 3dcc475a2de21..18c437da224da 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts @@ -7,7 +7,7 @@ import { Stack } from '../stack'; */ export interface IStackSynthesizer { /** - * The qualifier for used to bootstrap this stack + * The qualifier used to bootstrap this stack * * @default - no qualifier */ diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index 18a20811474c4..a3c7b6137d548 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -433,7 +433,7 @@ export class Stack extends Construct implements ITaggable { props.permissionsBoundary?._bind(this); - // add the permission boundary aspect + // add the permissions boundary aspect this.addPermissionsBoundaryAspect(); } From 05a53ddb7d45e8364a4e4061bf706070f2fb2aaf Mon Sep 17 00:00:00 2001 From: corymhall <43035978+corymhall@users.noreply.github.com> Date: Tue, 15 Nov 2022 21:26:15 +0000 Subject: [PATCH 6/8] fixing things --- packages/@aws-cdk/aws-iam/test/role.test.ts | 100 ++++++++++++++++++-- packages/@aws-cdk/core/lib/stack.ts | 4 +- 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/packages/@aws-cdk/aws-iam/test/role.test.ts b/packages/@aws-cdk/aws-iam/test/role.test.ts index 2ccdcf71c4ca2..3d310dd623377 100644 --- a/packages/@aws-cdk/aws-iam/test/role.test.ts +++ b/packages/@aws-cdk/aws-iam/test/role.test.ts @@ -914,7 +914,7 @@ describe('permissions boundary', () => { const app = new App({ context: { [PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { - name: DefaultStackSynthesizer.DEFAULT_PERMISSIONS_BOUNDARY_NAME, + name: 'cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', }, }, }); @@ -927,7 +927,22 @@ describe('permissions boundary', () => { // THEN Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { - PermissionsBoundary: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-hnb659fds-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':policy/cdk-hnb659fds-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + ], + ], + }, }); }); @@ -946,7 +961,22 @@ describe('permissions boundary', () => { // THEN Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { - PermissionsBoundary: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-hnb659fds-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':policy/cdk-hnb659fds-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + ], + ], + }, }); }); @@ -969,7 +999,18 @@ describe('permissions boundary', () => { // THEN Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { - PermissionsBoundary: 'arn:${AWS::Partition}:iam::123456789012:policy/cdk-hnb659fds-PermissionsBoundary-123456789012-test-region', + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::123456789012:policy/cdk-hnb659fds-PermissionsBoundary-123456789012-test-region', + ], + ], + }, }); }); @@ -992,7 +1033,22 @@ describe('permissions boundary', () => { // THEN Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { - PermissionsBoundary: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-custom-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':policy/cdk-custom-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + ], + ], + }, }); }); @@ -1011,7 +1067,22 @@ describe('permissions boundary', () => { // THEN Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { - PermissionsBoundary: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/my-permissions-boundary', + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':policy/my-permissions-boundary', + ], + ], + }, }); }); @@ -1034,7 +1105,22 @@ describe('permissions boundary', () => { // THEN Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { - PermissionsBoundary: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/my-custom-permissions-boundary', + PermissionsBoundary: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':policy/my-custom-permissions-boundary', + ], + ], + }, }); }); diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index a3c7b6137d548..5bb649b0d2a77 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -464,12 +464,10 @@ export class Stack extends Construct implements ITaggable { if (context && context.arn) { return context.arn; } else if (context && context.name) { - return Arn.format({ + return this.formatArn({ service: 'iam', resource: 'policy', region: '', - account: '${AWS::AccountId}', - partition: '${AWS::Partition}', resourceName: context.name, }); } From 2e4eca80dd9ecb499b06a19289e846f83f6e4cec Mon Sep 17 00:00:00 2001 From: corymhall <43035978+corymhall@users.noreply.github.com> Date: Wed, 16 Nov 2022 12:59:31 +0000 Subject: [PATCH 7/8] moving specializer out of the aspect --- packages/@aws-cdk/core/lib/stack.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index 5bb649b0d2a77..71c325305956f 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -460,16 +460,20 @@ export class Stack extends Construct implements ITaggable { * @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); if (context && context.arn) { - return context.arn; + return spec.specialize(context.arn); } else if (context && context.name) { - return this.formatArn({ + return spec.specialize(this.formatArn({ service: 'iam', resource: 'policy', region: '', resourceName: context.name, - }); + })); } return; } @@ -487,12 +491,7 @@ export class Stack extends Construct implements ITaggable { CfnResource.isCfnResource(node) && (node.cfnResourceType == 'AWS::IAM::Role' || node.cfnResourceType == 'AWS::IAM::User') ) { - const stack = Stack.of(node); - const qualifier = stack.synthesizer.bootstrapQualifier - ?? node.node.tryGetContext(BOOTSTRAP_QUALIFIER_CONTEXT) - ?? DefaultStackSynthesizer.DEFAULT_QUALIFIER; - const spec = new StringSpecializer(stack, qualifier); - node.addPropertyOverride('PermissionsBoundary', spec.specialize(permissionsBoundaryArn)); + node.addPropertyOverride('PermissionsBoundary', permissionsBoundaryArn); } }, }); From 154e24b8e63c8e36f3dd75f95059948871faa647 Mon Sep 17 00:00:00 2001 From: corymhall <43035978+corymhall@users.noreply.github.com> Date: Thu, 17 Nov 2022 13:49:47 +0000 Subject: [PATCH 8/8] updates to better handle pseudo parameters --- packages/@aws-cdk/aws-iam/README.md | 9 +- ...g-permissions-boundary-support.assets.json | 19 +++++ ...permissions-boundary-support.template.json | 70 ++++++++++++++++ .../integ-permissions-boundary.assets.json | 11 +-- .../integ-permissions-boundary.template.json | 2 +- .../integ.json | 1 + .../manifest.json | 64 ++++++++++++-- .../tree.json | 84 +++++++++++++++++++ .../test/integ.permissions-boundary.ts | 20 ++++- packages/@aws-cdk/aws-iam/test/role.test.ts | 12 +-- packages/@aws-cdk/core/README.md | 2 +- .../@aws-cdk/core/lib/permissions-boundary.ts | 16 ++-- packages/@aws-cdk/core/lib/stack.ts | 15 +++- packages/@aws-cdk/core/test/stack.test.ts | 73 ++++++++++++++++ packages/aws-cdk-lib/README.md | 2 +- 15 files changed, 363 insertions(+), 37 deletions(-) create mode 100644 packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary-support.assets.json create mode 100644 packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.js.snapshot/integ-permissions-boundary-support.template.json diff --git a/packages/@aws-cdk/aws-iam/README.md b/packages/@aws-cdk/aws-iam/README.md index a364c656ba75b..b63b628a731a3 100644 --- a/packages/@aws-cdk/aws-iam/README.md +++ b/packages/@aws-cdk/aws-iam/README.md @@ -426,13 +426,13 @@ 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-${AWS::AccountId}-${AWS::Region}` +`cdk-${Qualifier}-PermissionsBoundary` ```json { "context": { "@aws-cdk/core:permissionsBoundary": { - "name": "cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}" + "name": "cdk-${Qualifier}-PermissionsBoundary" } } } @@ -444,7 +444,7 @@ OR new App({ context: { [PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { - name: 'cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + name: 'cdk-${Qualifier}-PermissionsBoundary', }, }, }); @@ -481,7 +481,8 @@ new Stage(app, 'ProdStage', { ``` The provided name can include placeholders for the partition, region, qualifier, and account -These placeholders will be replaced with the actual values if available. +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}' 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 index b64f5a901a0eb..a6bf5f73331aa 100644 --- 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 @@ -1,16 +1,17 @@ { "version": "21.0.0", "files": { - "e44b37dd46a689c029230d5e93d15c50a378ef25ec3b354d2ffcd3caa38adc6c": { + "8e762109b39d7f7170aaa0acabaf6222a2b8f707b4c979de97254aab0338a069": { "source": { "path": "integ-permissions-boundary.template.json", "packaging": "file" }, "destinations": { - "current_account-current_region": { - "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", - "objectKey": "e44b37dd46a689c029230d5e93d15c50a378ef25ec3b354d2ffcd3caa38adc6c.json", - "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + "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" } } } 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 index c7824bad86a04..e9fb97655d50e 100644 --- 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 @@ -23,7 +23,7 @@ { "Ref": "AWS::Partition" }, - ":iam::aws:policy/AdministratorAccess" + ":iam::12345678:policy/cdk-hnb659fds-PermissionsBoundary-12345678-test-region" ] ] } 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 index 4af82ec5a3a3b..8945abd313413 100644 --- 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 @@ -1,4 +1,5 @@ { + "enableLookups": true, "version": "21.0.0", "testCases": { "integ-test/DefaultTest": { 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 index 550276a13e0ff..5156dd9110686 100644 --- 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 @@ -1,6 +1,59 @@ { "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": { @@ -11,25 +64,26 @@ }, "integ-permissions-boundary": { "type": "aws:cloudformation:stack", - "environment": "aws://unknown-account/unknown-region", + "environment": "aws://12345678/test-region", "properties": { "templateFile": "integ-permissions-boundary.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}/e44b37dd46a689c029230d5e93d15c50a378ef25ec3b354d2ffcd3caa38adc6c.json", + "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::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "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": { 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 index f3fe83556ed90..3d7bf4a33c584 100644 --- 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 @@ -4,6 +4,90 @@ "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", diff --git a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.ts b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.ts index 794aa2c96ce94..c9b36678ef532 100644 --- a/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.ts +++ b/packages/@aws-cdk/aws-iam/test/integ.permissions-boundary.ts @@ -1,12 +1,27 @@ import { App, Stack, PermissionsBoundary } from '@aws-cdk/core'; import { IntegTest } from '@aws-cdk/integ-tests'; -import { Role, ServicePrincipal, ManagedPolicy } from '../lib'; +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', { - permissionsBoundary: PermissionsBoundary.fromArn(ManagedPolicy.fromAwsManagedPolicyName('AdministratorAccess').managedPolicyArn), + 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'), @@ -14,4 +29,5 @@ new Role(stack, 'TestRole', { 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 3d310dd623377..85e1ee38e6fdb 100644 --- a/packages/@aws-cdk/aws-iam/test/role.test.ts +++ b/packages/@aws-cdk/aws-iam/test/role.test.ts @@ -914,7 +914,7 @@ describe('permissions boundary', () => { const app = new App({ context: { [PERMISSIONS_BOUNDARY_CONTEXT_KEY]: { - name: 'cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + name: 'cdk-${Qualifier}-PermissionsBoundary', }, }, }); @@ -939,7 +939,7 @@ describe('permissions boundary', () => { { Ref: 'AWS::AccountId', }, - ':policy/cdk-hnb659fds-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + ':policy/cdk-hnb659fds-PermissionsBoundary', ], ], }, @@ -950,7 +950,7 @@ describe('permissions boundary', () => { // GIVEN const app = new App(); const stage = new Stage(app, 'Stage', { - permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary'), }); const stack = new Stack(stage); @@ -973,7 +973,7 @@ describe('permissions boundary', () => { { Ref: 'AWS::AccountId', }, - ':policy/cdk-hnb659fds-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + ':policy/cdk-hnb659fds-PermissionsBoundary', ], ], }, @@ -1018,7 +1018,7 @@ describe('permissions boundary', () => { // GIVEN const app = new App(); const stage = new Stage(app, 'Stage', { - permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary'), }); const stack = new Stack(stage, 'MyStack', { synthesizer: new DefaultStackSynthesizer({ @@ -1045,7 +1045,7 @@ describe('permissions boundary', () => { { Ref: 'AWS::AccountId', }, - ':policy/cdk-custom-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}', + ':policy/cdk-custom-PermissionsBoundary', ], ], }, diff --git a/packages/@aws-cdk/core/README.md b/packages/@aws-cdk/core/README.md index 7718ddc27c724..162b60efac881 100644 --- a/packages/@aws-cdk/core/README.md +++ b/packages/@aws-cdk/core/README.md @@ -1216,7 +1216,7 @@ be to apply a permissions boundary at the `Stage` level. declare const app: App; const prodStage = new Stage(app, 'ProdStage', { - permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary'), }); ``` diff --git a/packages/@aws-cdk/core/lib/permissions-boundary.ts b/packages/@aws-cdk/core/lib/permissions-boundary.ts index 6f563879bf72f..a80ae44363862 100644 --- a/packages/@aws-cdk/core/lib/permissions-boundary.ts +++ b/packages/@aws-cdk/core/lib/permissions-boundary.ts @@ -1,6 +1,4 @@ import { Construct } from 'constructs'; -import { Arn } from './arn'; -import { Token } from './token'; export const PERMISSIONS_BOUNDARY_CONTEXT_KEY = '@aws-cdk/core:permissionsBoundary'; /** @@ -44,7 +42,9 @@ export class PermissionsBoundary { * 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. + * 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}' @@ -68,7 +68,9 @@ export class PermissionsBoundary { * 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. + * 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}' @@ -84,11 +86,7 @@ export class PermissionsBoundary { * }); */ public static fromArn(arn: string): PermissionsBoundary { - let name; - if (!Token.isUnresolved(arn)) { - name = Arn.parse(arn); - } - return new PermissionsBoundary(name?.resourceName, arn); + return new PermissionsBoundary(undefined, arn); } private constructor(private readonly policyName?: string, private readonly policyArn?: string) { diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index 71c325305956f..271e700e43939 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -465,17 +465,26 @@ export class Stack extends Construct implements ITaggable { ?? 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) { - return spec.specialize(context.arn); + arn = spec.specialize(context.arn); } else if (context && context.name) { - return spec.specialize(this.formatArn({ + arn = spec.specialize(this.formatArn({ service: 'iam', resource: 'policy', region: '', resourceName: context.name, })); } - return; + 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; } /** 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 785ffea34721e..da983bbf62ca5 100644 --- a/packages/aws-cdk-lib/README.md +++ b/packages/aws-cdk-lib/README.md @@ -1247,7 +1247,7 @@ be to apply a permissions boundary at the `Stage` level. declare const app: App; const prodStage = new Stage(app, 'ProdStage', { - permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary-${AWS::AccountId}-${AWS::Region}'), + permissionsBoundary: PermissionsBoundary.fromName('cdk-${Qualifier}-PermissionsBoundary'), }); ```