diff --git a/packages/@aws-cdk/aws-s3/README.md b/packages/@aws-cdk/aws-s3/README.md index 4f2592b9c633a..47138a3d30ec6 100644 --- a/packages/@aws-cdk/aws-s3/README.md +++ b/packages/@aws-cdk/aws-s3/README.md @@ -249,6 +249,33 @@ const bucket = s3.Bucket.fromBucketAttributes(this, 'ImportedBucket', { bucket.addEventNotification(s3.EventType.OBJECT_CREATED, new s3n.SnsDestination(topic)); ``` +When you add an event notification to a bucket, a custom resource is created to +manage the notifications. By default, a new role is created for the Lambda +function that implements this feature. If you want to use your own role instead, +you should provide it in the `Bucket` constructor: + +```ts +declare const myRole: iam.IRole; +const bucket = new s3.Bucket(this, 'MyBucket', { + notificationsHandlerRole: myRole, +}); +``` + +Whatever role you provide, the CDK will try to modify it by adding the +permissions from `AWSLambdaBasicExecutionRole` (an AWS managed policy) as well +as the permissions `s3:PutBucketNotification` and `s3:GetBucketNotification`. +If you’re passing an imported role, and you don’t want this to happen, configure +it to be immutable: + +```ts +const importedRole = iam.Role.fromRoleArn(this, 'role', 'arn:aws:iam::123456789012:role/RoleName', { + mutable: false, +}); +``` + +> If you provide an imported immutable role, make sure that it has at least all +> the permissions mentioned above. Otherwise, the deployment will fail! + [S3 Bucket Notifications]: https://docs.aws.amazon.com/AmazonS3/latest/dev/NotificationHowTo.html diff --git a/packages/@aws-cdk/aws-s3/lib/bucket.ts b/packages/@aws-cdk/aws-s3/lib/bucket.ts index a6c39016bf9cc..6cc76d3736488 100644 --- a/packages/@aws-cdk/aws-s3/lib/bucket.ts +++ b/packages/@aws-cdk/aws-s3/lib/bucket.ts @@ -427,6 +427,13 @@ export interface BucketAttributes { * @default - it's assumed the bucket is in the same region as the scope it's being imported into */ readonly region?: string; + + /** + * The role to be used by the notifications handler + * + * @default - a new role will be created. + */ + readonly notificationsHandlerRole?: iam.IRole; } /** @@ -484,14 +491,12 @@ export abstract class BucketBase extends Resource implements IBucket { */ protected abstract disallowPublicAccess?: boolean; - private readonly notifications: BucketNotifications; + private notifications?: BucketNotifications; + + protected notificationsHandlerRole?: iam.IRole; constructor(scope: Construct, id: string, props: ResourceProps = {}) { super(scope, id, props); - - // defines a BucketNotifications construct. Notice that an actual resource will only - // be added if there are notifications added, so we don't need to condition this. - this.notifications = new BucketNotifications(this, 'Notifications', { bucket: this }); } /** @@ -836,7 +841,17 @@ export abstract class BucketBase extends Resource implements IBucket { * https://docs.aws.amazon.com/AmazonS3/latest/dev/NotificationHowTo.html */ public addEventNotification(event: EventType, dest: IBucketNotificationDestination, ...filters: NotificationKeyFilter[]) { - this.notifications.addNotification(event, dest, ...filters); + this.withNotifications(notifications => notifications.addNotification(event, dest, ...filters)); + } + + private withNotifications(cb: (notifications: BucketNotifications) => void) { + if (!this.notifications) { + this.notifications = new BucketNotifications(this, 'Notifications', { + bucket: this, + handlerRole: this.notificationsHandlerRole, + }); + } + cb(this.notifications); } /** @@ -1459,6 +1474,13 @@ export interface BucketProps { */ readonly transferAcceleration?: boolean; + /** + * The role to be used by the notifications handler + * + * @default - a new role will be created. + */ + readonly notificationsHandlerRole?: iam.IRole; + /** * Inteligent Tiering Configurations * @@ -1542,6 +1564,7 @@ export class Bucket extends BucketBase { public policy?: BucketPolicy = undefined; protected autoCreatePolicy = false; protected disallowPublicAccess = false; + protected notificationsHandlerRole = attrs.notificationsHandlerRole; /** * Exports this bucket from the stack. @@ -1629,6 +1652,8 @@ export class Bucket extends BucketBase { physicalName: props.bucketName, }); + this.notificationsHandlerRole = props.notificationsHandlerRole; + const { bucketEncryption, encryptionKey } = this.parseEncryption(props); Bucket.validateBucketName(this.physicalName); diff --git a/packages/@aws-cdk/aws-s3/lib/notifications-resource/notifications-resource-handler.ts b/packages/@aws-cdk/aws-s3/lib/notifications-resource/notifications-resource-handler.ts index c093487a8f105..5a3955a96da9f 100644 --- a/packages/@aws-cdk/aws-s3/lib/notifications-resource/notifications-resource-handler.ts +++ b/packages/@aws-cdk/aws-s3/lib/notifications-resource/notifications-resource-handler.ts @@ -7,6 +7,10 @@ import * as cdk from '@aws-cdk/core'; // eslint-disable-next-line no-duplicate-imports, import/order import { Construct } from '@aws-cdk/core'; +export class NotificationsResourceHandlerProps { + role?: iam.IRole; +} + /** * A Lambda-based custom resource handler that provisions S3 bucket * notifications for a bucket. @@ -31,14 +35,14 @@ export class NotificationsResourceHandler extends Construct { * * @returns The ARN of the custom resource lambda function. */ - public static singleton(context: Construct) { + public static singleton(context: Construct, props: NotificationsResourceHandlerProps = {}) { const root = cdk.Stack.of(context); // well-known logical id to ensure stack singletonity const logicalId = 'BucketNotificationsHandler050a0587b7544547bf325f094a3db834'; let lambda = root.node.tryFindChild(logicalId) as NotificationsResourceHandler; if (!lambda) { - lambda = new NotificationsResourceHandler(root, logicalId); + lambda = new NotificationsResourceHandler(root, logicalId, props); } return lambda; @@ -53,19 +57,19 @@ export class NotificationsResourceHandler extends Construct { /** * The role of the handler's lambda function. */ - public readonly role: iam.Role; + public readonly role: iam.IRole; - constructor(scope: Construct, id: string) { + constructor(scope: Construct, id: string, props: NotificationsResourceHandlerProps = {}) { super(scope, id); - this.role = new iam.Role(this, 'Role', { + this.role = props.role ?? new iam.Role(this, 'Role', { assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), - managedPolicies: [ - iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'), - ], }); - this.role.addToPolicy(new iam.PolicyStatement({ + this.role.addManagedPolicy( + iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'), + ); + this.role.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['s3:PutBucketNotification'], resources: ['*'], })); @@ -95,4 +99,8 @@ export class NotificationsResourceHandler extends Construct { this.functionArn = resource.getAtt('Arn').toString(); } + + public addToRolePolicy(statement: iam.PolicyStatement) { + this.role.addToPrincipalPolicy(statement); + } } diff --git a/packages/@aws-cdk/aws-s3/lib/notifications-resource/notifications-resource.ts b/packages/@aws-cdk/aws-s3/lib/notifications-resource/notifications-resource.ts index d5190f1a6a913..6bc50ec5b6064 100644 --- a/packages/@aws-cdk/aws-s3/lib/notifications-resource/notifications-resource.ts +++ b/packages/@aws-cdk/aws-s3/lib/notifications-resource/notifications-resource.ts @@ -13,6 +13,11 @@ interface NotificationsProps { * The bucket to manage notifications for. */ bucket: IBucket; + + /** + * The role to be used by the lambda handler + */ + handlerRole?: iam.IRole; } /** @@ -36,10 +41,12 @@ export class BucketNotifications extends Construct { private readonly topicNotifications = new Array(); private resource?: cdk.CfnResource; private readonly bucket: IBucket; + private readonly handlerRole?: iam.IRole; constructor(scope: Construct, id: string, props: NotificationsProps) { super(scope, id); this.bucket = props.bucket; + this.handlerRole = props.handlerRole; } /** @@ -102,12 +109,14 @@ export class BucketNotifications extends Construct { */ private createResourceOnce() { if (!this.resource) { - const handler = NotificationsResourceHandler.singleton(this); + const handler = NotificationsResourceHandler.singleton(this, { + role: this.handlerRole, + }); const managed = this.bucket instanceof Bucket; if (!managed) { - handler.role.addToPolicy(new iam.PolicyStatement({ + handler.addToRolePolicy(new iam.PolicyStatement({ actions: ['s3:GetBucketNotification'], resources: ['*'], })); diff --git a/packages/@aws-cdk/aws-s3/test/integ.bucket-auto-delete-objects.expected.json b/packages/@aws-cdk/aws-s3/test/integ.bucket-auto-delete-objects.expected.json index f5cf756e8a75d..da2c8cf503fe6 100644 --- a/packages/@aws-cdk/aws-s3/test/integ.bucket-auto-delete-objects.expected.json +++ b/packages/@aws-cdk/aws-s3/test/integ.bucket-auto-delete-objects.expected.json @@ -110,7 +110,7 @@ "Properties": { "Code": { "S3Bucket": { - "Ref": "AssetParameters84e9b89449fe2573e51d08cc143e21116ed4608c6db56afffcb4ad85c8130709S3Bucket2C6C817C" + "Ref": "AssetParametersbe270bbdebe0851c887569796e3997437cca54ce86893ed94788500448e92824S3Bucket09A62232" }, "S3Key": { "Fn::Join": [ @@ -123,7 +123,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters84e9b89449fe2573e51d08cc143e21116ed4608c6db56afffcb4ad85c8130709S3VersionKeyFA215BD6" + "Ref": "AssetParametersbe270bbdebe0851c887569796e3997437cca54ce86893ed94788500448e92824S3VersionKeyA28118BE" } ] } @@ -136,7 +136,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters84e9b89449fe2573e51d08cc143e21116ed4608c6db56afffcb4ad85c8130709S3VersionKeyFA215BD6" + "Ref": "AssetParametersbe270bbdebe0851c887569796e3997437cca54ce86893ed94788500448e92824S3VersionKeyA28118BE" } ] } @@ -228,7 +228,7 @@ "Properties": { "Code": { "S3Bucket": { - "Ref": "AssetParameters618bbe9863c0edd5c4ca2e24b5063762f020fafec018cd06f57e2bd9f2f48abfS3BucketE1985B35" + "Ref": "AssetParameters31552cb1c5c4cdb0d9502dc59c3cd63cb519dcb7e320e60965c75940297ae3b6S3BucketB51EC107" }, "S3Key": { "Fn::Join": [ @@ -241,7 +241,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters618bbe9863c0edd5c4ca2e24b5063762f020fafec018cd06f57e2bd9f2f48abfS3VersionKey610C6DE2" + "Ref": "AssetParameters31552cb1c5c4cdb0d9502dc59c3cd63cb519dcb7e320e60965c75940297ae3b6S3VersionKey2B267DB5" } ] } @@ -254,7 +254,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters618bbe9863c0edd5c4ca2e24b5063762f020fafec018cd06f57e2bd9f2f48abfS3VersionKey610C6DE2" + "Ref": "AssetParameters31552cb1c5c4cdb0d9502dc59c3cd63cb519dcb7e320e60965c75940297ae3b6S3VersionKey2B267DB5" } ] } @@ -297,29 +297,29 @@ } }, "Parameters": { - "AssetParameters84e9b89449fe2573e51d08cc143e21116ed4608c6db56afffcb4ad85c8130709S3Bucket2C6C817C": { + "AssetParametersbe270bbdebe0851c887569796e3997437cca54ce86893ed94788500448e92824S3Bucket09A62232": { "Type": "String", - "Description": "S3 bucket for asset \"84e9b89449fe2573e51d08cc143e21116ed4608c6db56afffcb4ad85c8130709\"" + "Description": "S3 bucket for asset \"be270bbdebe0851c887569796e3997437cca54ce86893ed94788500448e92824\"" }, - "AssetParameters84e9b89449fe2573e51d08cc143e21116ed4608c6db56afffcb4ad85c8130709S3VersionKeyFA215BD6": { + "AssetParametersbe270bbdebe0851c887569796e3997437cca54ce86893ed94788500448e92824S3VersionKeyA28118BE": { "Type": "String", - "Description": "S3 key for asset version \"84e9b89449fe2573e51d08cc143e21116ed4608c6db56afffcb4ad85c8130709\"" + "Description": "S3 key for asset version \"be270bbdebe0851c887569796e3997437cca54ce86893ed94788500448e92824\"" }, - "AssetParameters84e9b89449fe2573e51d08cc143e21116ed4608c6db56afffcb4ad85c8130709ArtifactHash17D48178": { + "AssetParametersbe270bbdebe0851c887569796e3997437cca54ce86893ed94788500448e92824ArtifactHash76F8FCF2": { "Type": "String", - "Description": "Artifact hash for asset \"84e9b89449fe2573e51d08cc143e21116ed4608c6db56afffcb4ad85c8130709\"" + "Description": "Artifact hash for asset \"be270bbdebe0851c887569796e3997437cca54ce86893ed94788500448e92824\"" }, - "AssetParameters618bbe9863c0edd5c4ca2e24b5063762f020fafec018cd06f57e2bd9f2f48abfS3BucketE1985B35": { + "AssetParameters31552cb1c5c4cdb0d9502dc59c3cd63cb519dcb7e320e60965c75940297ae3b6S3BucketB51EC107": { "Type": "String", - "Description": "S3 bucket for asset \"618bbe9863c0edd5c4ca2e24b5063762f020fafec018cd06f57e2bd9f2f48abf\"" + "Description": "S3 bucket for asset \"31552cb1c5c4cdb0d9502dc59c3cd63cb519dcb7e320e60965c75940297ae3b6\"" }, - "AssetParameters618bbe9863c0edd5c4ca2e24b5063762f020fafec018cd06f57e2bd9f2f48abfS3VersionKey610C6DE2": { + "AssetParameters31552cb1c5c4cdb0d9502dc59c3cd63cb519dcb7e320e60965c75940297ae3b6S3VersionKey2B267DB5": { "Type": "String", - "Description": "S3 key for asset version \"618bbe9863c0edd5c4ca2e24b5063762f020fafec018cd06f57e2bd9f2f48abf\"" + "Description": "S3 key for asset version \"31552cb1c5c4cdb0d9502dc59c3cd63cb519dcb7e320e60965c75940297ae3b6\"" }, - "AssetParameters618bbe9863c0edd5c4ca2e24b5063762f020fafec018cd06f57e2bd9f2f48abfArtifactHash467DFC33": { + "AssetParameters31552cb1c5c4cdb0d9502dc59c3cd63cb519dcb7e320e60965c75940297ae3b6ArtifactHashEE982197": { "Type": "String", - "Description": "Artifact hash for asset \"618bbe9863c0edd5c4ca2e24b5063762f020fafec018cd06f57e2bd9f2f48abf\"" + "Description": "Artifact hash for asset \"31552cb1c5c4cdb0d9502dc59c3cd63cb519dcb7e320e60965c75940297ae3b6\"" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3/test/integ.bucket-inventory.expected.json b/packages/@aws-cdk/aws-s3/test/integ.bucket-inventory.expected.json index f5610756ad71e..a142de99be8b0 100644 --- a/packages/@aws-cdk/aws-s3/test/integ.bucket-inventory.expected.json +++ b/packages/@aws-cdk/aws-s3/test/integ.bucket-inventory.expected.json @@ -155,4 +155,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3/test/integ.bucket-sharing.lit.expected.json b/packages/@aws-cdk/aws-s3/test/integ.bucket-sharing.lit.expected.json index 4197e9179b4ff..083db4157734a 100644 --- a/packages/@aws-cdk/aws-s3/test/integ.bucket-sharing.lit.expected.json +++ b/packages/@aws-cdk/aws-s3/test/integ.bucket-sharing.lit.expected.json @@ -71,4 +71,4 @@ } } } -] +] \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3/test/integ.bucket.expected.json b/packages/@aws-cdk/aws-s3/test/integ.bucket.expected.json index 58cd3c5760961..b58901930d7c1 100644 --- a/packages/@aws-cdk/aws-s3/test/integ.bucket.expected.json +++ b/packages/@aws-cdk/aws-s3/test/integ.bucket.expected.json @@ -173,4 +173,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3/test/integ.bucket.url.lit.expected.json b/packages/@aws-cdk/aws-s3/test/integ.bucket.url.lit.expected.json index 37a7d24a40029..6ab5dcbfef26e 100644 --- a/packages/@aws-cdk/aws-s3/test/integ.bucket.url.lit.expected.json +++ b/packages/@aws-cdk/aws-s3/test/integ.bucket.url.lit.expected.json @@ -44,7 +44,10 @@ [ "https://", { - "Fn::GetAtt": ["MyBucketF68F3FF0", "RegionalDomainName"] + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "RegionalDomainName" + ] }, "/myfolder/myfile.txt" ] @@ -58,7 +61,10 @@ [ "https://", { - "Fn::GetAtt": ["MyBucketF68F3FF0", "DomainName"] + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "DomainName" + ] }, "/myfolder/myfile.txt" ] @@ -80,4 +86,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3/test/notification.test.ts b/packages/@aws-cdk/aws-s3/test/notification.test.ts index fbc8e1aa45a49..411852018d081 100644 --- a/packages/@aws-cdk/aws-s3/test/notification.test.ts +++ b/packages/@aws-cdk/aws-s3/test/notification.test.ts @@ -1,4 +1,5 @@ import { Template } from '@aws-cdk/assertions'; +import * as iam from '@aws-cdk/aws-iam'; import * as cdk from '@aws-cdk/core'; import * as s3 from '../lib'; @@ -30,6 +31,29 @@ describe('notification', () => { }); }); + test('can specify a custom role for the notifications handler of imported buckets', () => { + const stack = new cdk.Stack(); + + const importedRole = iam.Role.fromRoleArn(stack, 'role', 'arn:aws:iam::111111111111:role/DevsNotAllowedToTouch'); + + const bucket = s3.Bucket.fromBucketAttributes(stack, 'MyBucket', { + bucketName: 'foo-bar', + notificationsHandlerRole: importedRole, + }); + + bucket.addEventNotification(s3.EventType.OBJECT_CREATED, { + bind: () => ({ + arn: 'ARN', + type: s3.BucketNotificationDestinationType.TOPIC, + }), + }); + + Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', { + Description: 'AWS CloudFormation handler for "Custom::S3BucketNotifications" resources (@aws-cdk/aws-s3)', + Role: 'arn:aws:iam::111111111111:role/DevsNotAllowedToTouch', + }); + }); + test('can specify prefix and suffix filter rules', () => { const stack = new cdk.Stack();