Skip to content

Commit

Permalink
implement cloudfront.OriginAccessControl construct
Browse files Browse the repository at this point in the history
  • Loading branch information
AMZN-hgoffin committed Mar 29, 2023
1 parent d969ddf commit 003f70a
Show file tree
Hide file tree
Showing 27 changed files with 2,144 additions and 9 deletions.
101 changes: 94 additions & 7 deletions packages/@aws-cdk/aws-cloudfront-origins/lib/s3-origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,34 @@ import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as iam from '@aws-cdk/aws-iam';
import * as s3 from '@aws-cdk/aws-s3';
import * as cdk from '@aws-cdk/core';
import { RemovalPolicy } from '@aws-cdk/core';
import { Construct } from 'constructs';
import { HttpOrigin } from './http-origin';

/**
* Properties to use to customize an S3 Origin.
*/
export interface S3OriginProps extends cloudfront.OriginProps {
/**
* When set to `true`, S3Origin will automatically configure IAM policies on the target S3 bucket
* and any attached SSE-KMS key policy to permit read-only access by the CloudFront distribution.
* In addition, if the `originAccessControl` property is not specified, a default Origin Access
* Control will be attached to the CloudFront distribution.
*
* This property is incompatible with the legacy `originAccessIdentity` property.
*
* @default false
*/
readonly autoGrantOACPermissions?: boolean;

/**
* An optional Origin Access Identity of the origin identity cloudfront will use when calling your s3 bucket.
* This is a legacy feature and in most cases, you should prefer to use Origin Access Control properties such
* as `originAccessControl` and `autoGrantOACPermissions` instead.
*
* @see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html
*
* @default - An Origin Access Identity will be created.
* @default - An Origin Access Identity will be created, unless `originAccessControl` or `applyOACBucketPolicy` is specified
*/
readonly originAccessIdentity?: cloudfront.IOriginAccessIdentity;
}
Expand All @@ -28,6 +45,9 @@ export class S3Origin implements cloudfront.IOrigin {
private readonly origin: cloudfront.IOrigin;

constructor(bucket: s3.IBucket, props: S3OriginProps = {}) {
if (bucket.isWebsite && props.originAccessControl) {
throw new Error('Origin Access Control does not apply to S3 buckets configured for website hosting');
}
this.origin = bucket.isWebsite ?
new HttpOrigin(bucket.bucketWebsiteDomainName, {
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY, // S3 only supports HTTP for website buckets
Expand All @@ -41,22 +61,86 @@ export class S3Origin implements cloudfront.IOrigin {
}
}

/**
* An origin-internal IOriginAccessControl wrapper which allows the creation of
* the OriginAccessControl construct to be deferred until bind() is called on the
* origin, which provides the necessary scope for the construct.
*/
class LateBindOAC implements cloudfront.IOriginAccessControl {
public bound?: cloudfront.IOriginAccessControl;
get env() { return this.bound!.env; }
get node() { return this.bound!.node; }
get stack() { return this.bound!.stack; }
get originAccessControlId() { return this.bound!.originAccessControlId; }
applyRemovalPolicy(policy: RemovalPolicy) { return this.bound!.applyRemovalPolicy(policy); }
}

/**
* An Origin specific to a S3 bucket (not configured for website hosting).
*
* Contains additional logic around bucket permissions and origin access identities.
*/
class S3BucketOrigin extends cloudfront.OriginBase {
private originAccessIdentity!: cloudfront.IOriginAccessIdentity;

constructor(private readonly bucket: s3.IBucket, { originAccessIdentity, ...props }: S3OriginProps) {
super(bucket.bucketRegionalDomainName, props);
if (originAccessIdentity) {
this.originAccessIdentity = originAccessIdentity;
private static preprocessProps(props: S3OriginProps): S3OriginProps {
if (props.autoGrantOACPermissions && !props.originAccessControl) {
return { originAccessControl: new LateBindOAC(), ...props };
}
return props;
}

private originAccessIdentity?: cloudfront.IOriginAccessIdentity;
private readonly autoGrantOACPermissions: boolean;
private lateBindAutoCreateOAC?: LateBindOAC;

constructor(private readonly bucket: s3.IBucket, props: S3OriginProps) {
super(bucket.bucketRegionalDomainName, S3BucketOrigin.preprocessProps(props));

this.originAccessIdentity = props.originAccessIdentity;
this.autoGrantOACPermissions = !!props.autoGrantOACPermissions;

// preprocessProps may have created a LateBindOAC that we need to process in bind()
if (!props.originAccessControl && this.originAccessControl instanceof LateBindOAC) {
this.lateBindAutoCreateOAC = this.originAccessControl as LateBindOAC;
}

if (this.originAccessControl && this.originAccessIdentity) {
throw new Error('Origin Access Control cannot be combined with Origin Access Identity');
}
}

public bind(scope: Construct, options: cloudfront.OriginBindOptions): cloudfront.OriginBindConfig {
if (this.originAccessControl) {
if (this.lateBindAutoCreateOAC) {
this.lateBindAutoCreateOAC.bound = cloudfront.OriginAccessControl.fromS3Defaults(scope);
}
if (this.autoGrantOACPermissions) {
//if (cdk.Stack.of(this.bucket) !== cdk.Stack.of(scope)) {
// throw new Error('autoGrantOACPermissions cannot be used on buckets from other stacks');
//}
const dist = scope.node.scope as cloudfront.Distribution;
const lazyDistArn = cdk.Lazy.string({ produce: () => dist.distributionArn });
const added = this.bucket.addToResourcePolicy(new iam.PolicyStatement({
principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
actions: ['s3:GetObject'],
resources: [this.bucket.arnForObjects('*')],
conditions: { StringEquals: { 'aws:SourceArn': lazyDistArn } },
}));
if (!added.statementAdded) {
throw new Error('autoGrantOACPermissions cannot be used on imported buckets or buckets with non-CDK policies');
}
if (this.bucket.encryptionKey) {
this.bucket.encryptionKey.addToResourcePolicy(new iam.PolicyStatement({
principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
actions: ['kms:Decrypt'],
resources: ['*'],
conditions: { StringEquals: { 'aws:SourceArn': lazyDistArn } },
}), false /*do not allow failures, throw error instead*/);
}
}
return super.bind(scope, options);
}

if (!this.originAccessIdentity) {
// Using a bucket from another stack creates a cyclic reference with
// the bucket taking a dependency on the generated S3CanonicalUserId for the grant principal,
Expand Down Expand Up @@ -84,6 +168,9 @@ class S3BucketOrigin extends cloudfront.OriginBase {
}

protected renderS3OriginConfig(): cloudfront.CfnDistribution.S3OriginConfigProperty | undefined {
return { originAccessIdentity: `origin-access-identity/cloudfront/${this.originAccessIdentity.originAccessIdentityId}` };
if (this.originAccessControl) {
return { };
}
return { originAccessIdentity: `origin-access-identity/cloudfront/${this.originAccessIdentity!.originAccessIdentityId}` };
}
}
119 changes: 119 additions & 0 deletions packages/@aws-cdk/aws-cloudfront-origins/test/s3-origin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,125 @@ describe('With bucket', () => {
});
});

test('can use OriginAccessCotrol', () => {
const bucket = new s3.Bucket(stack, 'Bucket');

const oac = cloudfront.OriginAccessControl.fromS3Defaults(stack);
const origin = new S3Origin(bucket, { originAccessControl: oac });
new cloudfront.Distribution(stack, 'Dist', { defaultBehavior: { origin } });

const oacSingletonRef = 'OriginAccessControlACB7EFE0CA7DB170D0C7D8E8DC4943CFAFE70B28';

const tmpl = Template.fromStack(stack);
tmpl.hasResourceProperties('AWS::CloudFront::OriginAccessControl', {
OriginAccessControlConfig: {
OriginAccessControlOriginType: 's3',
SigningBehavior: 'always',
SigningProtocol: 'sigv4',
},
});
tmpl.hasResourceProperties('AWS::CloudFront::Distribution', {
DistributionConfig: {
Origins: [{
OriginAccessControlId: Match.exact({ Ref: oacSingletonRef }),
S3OriginConfig: Match.exact({}),
}],
},
});
});

test('can use OriginAccessCotrol with automatic permissions', () => {
const bucket = new s3.Bucket(stack, 'Bucket');
const origin = new S3Origin(bucket, { autoGrantOACPermissions: true });
new cloudfront.Distribution(stack, 'Dist', { defaultBehavior: { origin } });

const oacSingletonRef = 'OriginAccessControlACB7EFE0CA7DB170D0C7D8E8DC4943CFAFE70B28';

const tmpl = Template.fromStack(stack);
tmpl.hasResourceProperties('AWS::CloudFront::OriginAccessControl', {
OriginAccessControlConfig: {
OriginAccessControlOriginType: 's3',
SigningBehavior: 'always',
SigningProtocol: 'sigv4',
},
});
tmpl.hasResourceProperties('AWS::CloudFront::Distribution', {
DistributionConfig: {
Origins: [{
DomainName: Match.exact({ 'Fn::GetAtt': ['Bucket83908E77', 'RegionalDomainName'] }),
OriginAccessControlId: Match.exact({ Ref: oacSingletonRef }),
S3OriginConfig: Match.exact({}),
}],
},
});
tmpl.hasResourceProperties('AWS::S3::BucketPolicy', {
PolicyDocument: {
Statement: [{
Action: 's3:GetObject',
Effect: 'Allow',
Principal: Match.exact({ Service: 'cloudfront.amazonaws.com' }),
Resource: Match.exact({ 'Fn::Join': ['', [Match.anyValue(), '/*']] }),
Condition: Match.exact({ StringEquals: { 'aws:SourceArn': Match.anyValue() } }),
}],
},
});
});

false && test('can use OriginAccessCotrol with KMS and automatic permissions', () => {

// XXX circular dependency! Distribution requires Bucket, Bucket requires Key,
// Key requires Distribution (because key policy can't be set after creation).
// We can crack this by forcing the Distribution to create with enabled=false,
// then use Lambda to adjust Key policy and optionally enable the Distribution.
// Distribution doesn't allow the explicit specification of a distribution ID.

const bucket = new s3.Bucket(stack, 'Bucket', { encryption: s3.BucketEncryption.KMS });
const origin = new S3Origin(bucket, { autoGrantOACPermissions: true });
new cloudfront.Distribution(stack, 'Dist', { defaultBehavior: { origin } });

const oacSingletonRef = 'OriginAccessControlACB7EFE0CA7DB170D0C7D8E8DC4943CFAFE70B28';

const tmpl = Template.fromStack(stack);
tmpl.hasResourceProperties('AWS::CloudFront::OriginAccessControl', {
OriginAccessControlConfig: {
OriginAccessControlOriginType: 's3',
SigningBehavior: 'always',
SigningProtocol: 'sigv4',
},
});
tmpl.hasResourceProperties('AWS::CloudFront::Distribution', {
DistributionConfig: {
Origins: [{
DomainName: Match.exact({ 'Fn::GetAtt': ['Bucket83908E77', 'RegionalDomainName'] }),
OriginAccessControlId: Match.exact({ Ref: oacSingletonRef }),
S3OriginConfig: Match.exact({}),
}],
},
});
tmpl.hasResourceProperties('AWS::S3::BucketPolicy', {
PolicyDocument: {
Statement: [{
Action: 's3:GetObject',
Effect: 'Allow',
Principal: Match.exact({ Service: 'cloudfront.amazonaws.com' }),
Resource: Match.exact({ 'Fn::Join': ['', [Match.anyValue(), '/*']] }),
Condition: Match.exact({ StringEquals: { 'aws:SourceArn': Match.anyValue() } }),
}],
},
});
tmpl.hasResourceProperties('AWS::KMS::KeyPolicy', {
PolicyDocument: {
Statement: [{
Action: 'kms:Decrypt',
Effect: 'Allow',
Principal: Match.exact({ Service: 'cloudfront.amazonaws.com' }),
Resource: '*',
Condition: Match.exact({ StringEquals: { 'aws:SourceArn': Match.anyValue() } }),
}],
},
});
});

test('Can set a custom originId', () => {
const bucket = new s3.Bucket(stack, 'Bucket');
const bucket2 = new s3.Bucket(stack, 'Bucket2');
Expand Down
13 changes: 13 additions & 0 deletions packages/@aws-cdk/aws-cloudfront/lib/distribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ export interface IDistribution extends IResource {
*/
readonly distributionId: string;

/**
* The ARN which uniquely identifies this distribution. When Origin Access Control
* is configured, this is the `aws:SourceArn` condition value used when evaluating
* IAM policy checks for origin resources.
*
* @attribute
*/
readonly distributionArn: string;

/**
* Adds an IAM policy statement associated with this distribution to an IAM
* principal's policy.
Expand Down Expand Up @@ -268,12 +277,14 @@ export class Distribution extends Resource implements IDistribution {
public readonly domainName: string;
public readonly distributionDomainName: string;
public readonly distributionId: string;
public readonly distributionArn: string;

constructor() {
super(scope, id);
this.domainName = attrs.domainName;
this.distributionDomainName = attrs.domainName;
this.distributionId = attrs.distributionId;
this.distributionArn = formatDistributionArn(this);
}

public grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant {
Expand All @@ -288,6 +299,7 @@ export class Distribution extends Resource implements IDistribution {
public readonly domainName: string;
public readonly distributionDomainName: string;
public readonly distributionId: string;
public readonly distributionArn: string;

private readonly defaultBehavior: CacheBehavior;
private readonly additionalBehaviors: CacheBehavior[] = [];
Expand Down Expand Up @@ -353,6 +365,7 @@ export class Distribution extends Resource implements IDistribution {
this.domainName = distribution.attrDomainName;
this.distributionDomainName = distribution.attrDomainName;
this.distributionId = distribution.ref;
this.distributionArn = formatDistributionArn(this);
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-cloudfront/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './function';
export * from './geo-restriction';
export * from './key-group';
export * from './origin';
export * from './origin-access-control';
export * from './origin-access-identity';
export * from './origin-request-policy';
export * from './public-key';
Expand Down
Loading

0 comments on commit 003f70a

Please sign in to comment.