Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(s3): custom role for the bucket notifications handler #17794

Merged
merged 11 commits into from
Jan 24, 2022
27 changes: 27 additions & 0 deletions packages/@aws-cdk/aws-s3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
39 changes: 32 additions & 7 deletions packages/@aws-cdk/aws-s3/lib/bucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,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;
otaviomacedo marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down Expand Up @@ -486,14 +493,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 });
}

/**
Expand Down Expand Up @@ -838,7 +843,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) {
iliapolo marked this conversation as resolved.
Show resolved Hide resolved
if (!this.notifications) {
this.notifications = new BucketNotifications(this, 'Notifications', {
bucket: this,
handlerRole: this.notificationsHandlerRole,
});
}
cb(this.notifications);
}

/**
Expand Down Expand Up @@ -866,7 +881,7 @@ export abstract class BucketBase extends Resource implements IBucket {
}

protected enableEventBridgeNotification() {
this.notifications.enableEventBridgeNotification();
this.withNotifications(notifications => notifications.enableEventBridgeNotification());
}

private get writeActions(): string[] {
Expand Down Expand Up @@ -1472,6 +1487,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
*
Expand Down Expand Up @@ -1555,6 +1577,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.
Expand Down Expand Up @@ -1643,6 +1666,8 @@ export class Bucket extends BucketBase {
physicalName: props.bucketName,
});

this.notificationsHandlerRole = props.notificationsHandlerRole;

const { bucketEncryption, encryptionKey } = this.parseEncryption(props);

Bucket.validateBucketName(this.physicalName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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;
otaviomacedo marked this conversation as resolved.
Show resolved Hide resolved

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: ['*'],
}));
Expand Down Expand Up @@ -95,4 +99,8 @@ export class NotificationsResourceHandler extends Construct {

this.functionArn = resource.getAtt('Arn').toString();
}

public addToRolePolicy(statement: iam.PolicyStatement) {
this.role.addToPrincipalPolicy(statement);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -37,10 +42,12 @@ export class BucketNotifications extends Construct {
private readonly topicNotifications = new Array<TopicConfiguration>();
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;
}

/**
Expand Down Expand Up @@ -109,12 +116,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: ['*'],
}));
Expand Down
47 changes: 47 additions & 0 deletions packages/@aws-cdk/aws-s3/test/notification.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -30,6 +31,52 @@ describe('notification', () => {
});
});

test('can specify a custom role for the notifications handler', () => {
const stack = new cdk.Stack();

const importedRole = iam.Role.fromRoleArn(stack, 'role', 'arn:aws:iam::111111111111:role/DevsNotAllowedToTouch');

const bucket = new s3.Bucket(stack, 'MyBucket', {
notificationsHandlerRole: importedRole,
});

bucket.addEventNotification(s3.EventType.OBJECT_CREATED, {
bind: () => ({
arn: 'ARN',
type: s3.BucketNotificationDestinationType.TOPIC,
}),
});

Template.fromStack(stack).resourceCountIs('AWS::S3::Bucket', 1);
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 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();

Expand Down