diff --git a/README.md b/README.md index 981bbc6..31c358a 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,25 @@ distribution: viewer-request: arn:aws:lambda:us-east-1:123:function:myFunc:version # lambda ARN including version ``` +#### Private S3 Content + +To restrict access to content that you serve from S3 you can mark as `private` your S3 origins: + +```yml +# serverless.yml + +distribution: + component: '@serverless/aws-cloudfront' + inputs: + origins: + - url: https://my-private-bucket.s3.amazonaws.com + private: true +``` + +A bucket policy will be added that grants CloudFront with access to the bucket objects. Note that it doesn't remove any existing permissions on the bucket. If users currently have permission to access the files in your bucket using Amazon S3 URLs you will need to manually remove those. + +This is documented in more detail here: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html + ### 4. Deploy ```console diff --git a/__mocks__/aws-sdk.js b/__mocks__/aws-sdk.js index 16f09f3..37f3cf4 100644 --- a/__mocks__/aws-sdk.js +++ b/__mocks__/aws-sdk.js @@ -19,20 +19,38 @@ const mockGetDistributionConfigPromise = promisifyMock(mockGetDistributionConfig const mockDeleteDistribution = jest.fn() const mockDeleteDistributionPromise = promisifyMock(mockDeleteDistribution) +const mockCreateCloudFrontOriginAccessIdentity = jest.fn() +const mockCreateCloudFrontOriginAccessIdentityPromise = promisifyMock( + mockCreateCloudFrontOriginAccessIdentity +) + +const mockPutBucketPolicy = jest.fn() +const mockPutBucketPolicyPromise = promisifyMock(mockPutBucketPolicy) + module.exports = { mockCreateDistribution, mockUpdateDistribution, mockGetDistributionConfig, mockDeleteDistribution, + mockCreateCloudFrontOriginAccessIdentity, + mockPutBucketPolicy, + + mockPutBucketPolicyPromise, mockCreateDistributionPromise, mockUpdateDistributionPromise, mockGetDistributionConfigPromise, mockDeleteDistributionPromise, + mockCreateCloudFrontOriginAccessIdentityPromise, CloudFront: jest.fn(() => ({ createDistribution: mockCreateDistribution, updateDistribution: mockUpdateDistribution, getDistributionConfig: mockGetDistributionConfig, - deleteDistribution: mockDeleteDistribution + deleteDistribution: mockDeleteDistribution, + createCloudFrontOriginAccessIdentity: mockCreateCloudFrontOriginAccessIdentity + })), + + S3: jest.fn(() => ({ + putBucketPolicy: mockPutBucketPolicy })) } diff --git a/__tests__/__snapshots__/custom-url-origin.test.js.snap b/__tests__/__snapshots__/custom-url-origin.test.js.snap index 1de4d51..4aebf38 100644 --- a/__tests__/__snapshots__/custom-url-origin.test.js.snap +++ b/__tests__/__snapshots__/custom-url-origin.test.js.snap @@ -7,6 +7,10 @@ Object { "Items": Array [], "Quantity": 0, }, + "CacheBehaviors": Object { + "Items": Array [], + "Quantity": 0, + }, "CallerReference": "1566599541192", "Comment": "", "DefaultCacheBehavior": Object { @@ -93,6 +97,10 @@ Object { exports[`Input origin as a custom url updates distribution 1`] = ` Object { "DistributionConfig": Object { + "CacheBehaviors": Object { + "Items": Array [], + "Quantity": 0, + }, "DefaultCacheBehavior": Object { "AllowedMethods": Object { "CachedMethods": Object { diff --git a/__tests__/__snapshots__/s3-origin.test.js.snap b/__tests__/__snapshots__/s3-origin.test.js.snap index 5b4658f..ae7b4c8 100644 --- a/__tests__/__snapshots__/s3-origin.test.js.snap +++ b/__tests__/__snapshots__/s3-origin.test.js.snap @@ -1,12 +1,184 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Input origin as an S3 bucket url creates distribution with S3 origin 1`] = ` +exports[`S3 origins When origin is an S3 URL only accessible via CloudFront creates distribution 1`] = ` Object { "DistributionConfig": Object { "Aliases": Object { "Items": Array [], "Quantity": 0, }, + "CacheBehaviors": Object { + "Items": Array [], + "Quantity": 0, + }, + "CallerReference": "1566599541192", + "Comment": "", + "DefaultCacheBehavior": Object { + "AllowedMethods": Object { + "CachedMethods": Object { + "Items": Array [ + "HEAD", + "GET", + ], + "Quantity": 2, + }, + "Items": Array [ + "HEAD", + "GET", + ], + "Quantity": 2, + }, + "Compress": false, + "DefaultTTL": 86400, + "FieldLevelEncryptionId": "", + "ForwardedValues": Object { + "Cookies": Object { + "Forward": "none", + }, + "Headers": Object { + "Items": Array [], + "Quantity": 0, + }, + "QueryString": false, + "QueryStringCacheKeys": Object { + "Items": Array [], + "Quantity": 0, + }, + }, + "LambdaFunctionAssociations": Object { + "Items": Array [], + "Quantity": 0, + }, + "MaxTTL": 31536000, + "MinTTL": 0, + "SmoothStreaming": false, + "TargetOriginId": "mybucket", + "TrustedSigners": Object { + "Enabled": false, + "Items": Array [], + "Quantity": 0, + }, + "ViewerProtocolPolicy": "redirect-to-https", + }, + "Enabled": true, + "HttpVersion": "http2", + "Origins": Object { + "Items": Array [ + Object { + "CustomHeaders": Object { + "Items": Array [], + "Quantity": 0, + }, + "DomainName": "mybucket.s3.amazonaws.com", + "Id": "mybucket", + "OriginPath": "", + "S3OriginConfig": Object { + "OriginAccessIdentity": "origin-access-identity/cloudfront/access-identity-xyz", + }, + }, + ], + "Quantity": 1, + }, + "PriceClass": "PriceClass_All", + }, +} +`; + +exports[`S3 origins When origin is an S3 URL only accessible via CloudFront updates distribution 1`] = ` +Object { + "DistributionConfig": Object { + "Aliases": Object { + "Items": Array [], + "Quantity": 0, + }, + "CacheBehaviors": Object { + "Items": Array [], + "Quantity": 0, + }, + "CallerReference": "1566599541192", + "Comment": "", + "DefaultCacheBehavior": Object { + "AllowedMethods": Object { + "CachedMethods": Object { + "Items": Array [ + "HEAD", + "GET", + ], + "Quantity": 2, + }, + "Items": Array [ + "HEAD", + "GET", + ], + "Quantity": 2, + }, + "Compress": false, + "DefaultTTL": 86400, + "FieldLevelEncryptionId": "", + "ForwardedValues": Object { + "Cookies": Object { + "Forward": "none", + }, + "Headers": Object { + "Items": Array [], + "Quantity": 0, + }, + "QueryString": false, + "QueryStringCacheKeys": Object { + "Items": Array [], + "Quantity": 0, + }, + }, + "LambdaFunctionAssociations": Object { + "Items": Array [], + "Quantity": 0, + }, + "MaxTTL": 31536000, + "MinTTL": 0, + "SmoothStreaming": false, + "TargetOriginId": "mybucket", + "TrustedSigners": Object { + "Enabled": false, + "Items": Array [], + "Quantity": 0, + }, + "ViewerProtocolPolicy": "redirect-to-https", + }, + "Enabled": true, + "HttpVersion": "http2", + "Origins": Object { + "Items": Array [ + Object { + "CustomHeaders": Object { + "Items": Array [], + "Quantity": 0, + }, + "DomainName": "mybucket.s3.amazonaws.com", + "Id": "mybucket", + "OriginPath": "", + "S3OriginConfig": Object { + "OriginAccessIdentity": "origin-access-identity/cloudfront/access-identity-xyz", + }, + }, + ], + "Quantity": 1, + }, + "PriceClass": "PriceClass_All", + }, +} +`; + +exports[`S3 origins When origin is an S3 bucket URL creates distribution 1`] = ` +Object { + "DistributionConfig": Object { + "Aliases": Object { + "Items": Array [], + "Quantity": 0, + }, + "CacheBehaviors": Object { + "Items": Array [], + "Quantity": 0, + }, "CallerReference": "1566599541192", "Comment": "", "DefaultCacheBehavior": Object { @@ -80,9 +252,13 @@ Object { } `; -exports[`Input origin as an S3 bucket url updates distribution 1`] = ` +exports[`S3 origins When origin is an S3 bucket URL updates distribution 1`] = ` Object { "DistributionConfig": Object { + "CacheBehaviors": Object { + "Items": Array [], + "Quantity": 0, + }, "DefaultCacheBehavior": Object { "AllowedMethods": Object { "CachedMethods": Object { diff --git a/__tests__/s3-origin.test.js b/__tests__/s3-origin.test.js index 7ac7f47..a5ef639 100644 --- a/__tests__/s3-origin.test.js +++ b/__tests__/s3-origin.test.js @@ -3,12 +3,14 @@ const { mockUpdateDistribution, mockCreateDistributionPromise, mockGetDistributionConfigPromise, - mockUpdateDistributionPromise + mockUpdateDistributionPromise, + mockCreateCloudFrontOriginAccessIdentityPromise, + mockPutBucketPolicy } = require('aws-sdk') const { createComponent, assertHasOrigin } = require('../test-utils') -describe('Input origin as an S3 bucket url', () => { +describe('S3 origins', () => { let component beforeEach(async () => { @@ -21,55 +23,156 @@ describe('Input origin as an S3 bucket url', () => { component = await createComponent() }) - it('creates distribution with S3 origin', async () => { - await component.default({ - origins: ['https://mybucket.s3.amazonaws.com'] - }) + describe('When origin is an S3 bucket URL', () => { + it('creates distribution', async () => { + await component.default({ + origins: ['https://mybucket.s3.amazonaws.com'] + }) + + assertHasOrigin(mockCreateDistribution, { + Id: 'mybucket', + DomainName: 'mybucket.s3.amazonaws.com', + S3OriginConfig: { + OriginAccessIdentity: '' + }, + CustomHeaders: { + Quantity: 0, + Items: [] + }, + OriginPath: '' + }) - assertHasOrigin(mockCreateDistribution, { - Id: 'mybucket', - DomainName: 'mybucket.s3.amazonaws.com', - S3OriginConfig: { - OriginAccessIdentity: '' - }, - CustomHeaders: { - Quantity: 0, - Items: [] - }, - OriginPath: '' + expect(mockCreateDistribution.mock.calls[0][0]).toMatchSnapshot() }) - expect(mockCreateDistribution.mock.calls[0][0]).toMatchSnapshot() + it('updates distribution', async () => { + mockGetDistributionConfigPromise.mockResolvedValueOnce({ + ETag: 'etag', + DistributionConfig: { + Origins: { + Items: [] + } + } + }) + mockUpdateDistributionPromise.mockResolvedValueOnce({ + Distribution: { + Id: 'distributionwithS3originupdated' + } + }) + + await component.default({ + origins: ['https://mybucket.s3.amazonaws.com'] + }) + + await component.default({ + origins: ['https://anotherbucket.s3.amazonaws.com'] + }) + + assertHasOrigin(mockUpdateDistribution, { + Id: 'anotherbucket', + DomainName: 'anotherbucket.s3.amazonaws.com' + }) + + expect(mockUpdateDistribution.mock.calls[0][0]).toMatchSnapshot() + }) }) - it('updates distribution', async () => { - mockGetDistributionConfigPromise.mockResolvedValueOnce({ - ETag: 'etag', - DistributionConfig: { - Origins: { - Items: [] + describe('When origin is an S3 URL only accessible via CloudFront', () => { + it('creates distribution', async () => { + mockCreateCloudFrontOriginAccessIdentityPromise.mockResolvedValueOnce({ + CloudFrontOriginAccessIdentity: { + Id: 'access-identity-xyz', + S3CanonicalUserId: 's3-canonical-user-id-xyz' } - } - }) - mockUpdateDistributionPromise.mockResolvedValueOnce({ - Distribution: { - Id: 'distributionwithS3originupdated' - } - }) + }) - await component.default({ - origins: ['https://mybucket.s3.amazonaws.com'] - }) + await component.default({ + origins: [ + { + url: 'https://mybucket.s3.amazonaws.com', + private: true + } + ] + }) - await component.default({ - origins: ['https://anotherbucket.s3.amazonaws.com'] - }) + expect(mockPutBucketPolicy).toBeCalledWith({ + Bucket: 'mybucket', + Policy: expect.stringContaining('"CanonicalUser":"s3-canonical-user-id-xyz"') + }) + + assertHasOrigin(mockCreateDistribution, { + Id: 'mybucket', + DomainName: 'mybucket.s3.amazonaws.com', + S3OriginConfig: { + OriginAccessIdentity: 'origin-access-identity/cloudfront/access-identity-xyz' + }, + CustomHeaders: { + Quantity: 0, + Items: [] + }, + OriginPath: '' + }) - assertHasOrigin(mockUpdateDistribution, { - Id: 'anotherbucket', - DomainName: 'anotherbucket.s3.amazonaws.com' + expect(mockCreateDistribution.mock.calls[0][0]).toMatchSnapshot() }) - expect(mockUpdateDistribution.mock.calls[0][0]).toMatchSnapshot() + it('updates distribution', async () => { + mockCreateCloudFrontOriginAccessIdentityPromise.mockResolvedValue({ + CloudFrontOriginAccessIdentity: { + Id: 'access-identity-xyz', + S3CanonicalUserId: 's3-canonical-user-id-xyz' + } + }) + + mockGetDistributionConfigPromise.mockResolvedValueOnce({ + ETag: 'etag', + DistributionConfig: { + Origins: { + Items: [] + } + } + }) + + mockUpdateDistributionPromise.mockResolvedValueOnce({ + Distribution: { + Id: 'distributionwithS3originupdated' + } + }) + + await component.default({ + origins: [ + { + url: 'https://mybucket.s3.amazonaws.com', + private: true + } + ] + }) + + await component.default({ + origins: [ + { + url: 'https://anotherbucket.s3.amazonaws.com', + private: true + } + ] + }) + + expect(mockPutBucketPolicy).toBeCalledWith({ + Bucket: 'anotherbucket', + Policy: expect.stringContaining('"CanonicalUser":"s3-canonical-user-id-xyz"') + }) + + assertHasOrigin(mockUpdateDistribution, { + Id: 'anotherbucket', + DomainName: 'anotherbucket.s3.amazonaws.com', + S3OriginConfig: { + OriginAccessIdentity: 'origin-access-identity/cloudfront/access-identity-xyz' + }, + OriginPath: '', + CustomHeaders: { Items: [], Quantity: 0 } + }) + + expect(mockCreateDistribution.mock.calls[0][0]).toMatchSnapshot() + }) }) }) diff --git a/lib/createOriginAccessIdentity.js b/lib/createOriginAccessIdentity.js new file mode 100644 index 0000000..7913fc7 --- /dev/null +++ b/lib/createOriginAccessIdentity.js @@ -0,0 +1,14 @@ +module.exports = async (cf) => { + const { + CloudFrontOriginAccessIdentity: { Id, S3CanonicalUserId } + } = await cf + .createCloudFrontOriginAccessIdentity({ + CloudFrontOriginAccessIdentityConfig: { + CallerReference: 'serverless-managed-cloudfront-access-identity', + Comment: 'CloudFront Origin Access Identity created to allow serving private S3 content' + } + }) + .promise() + + return { originAccessIdentityId: Id, s3CanonicalUserId: S3CanonicalUserId } +} diff --git a/lib/getOriginConfig.js b/lib/getOriginConfig.js index a7fe6c4..fdecd6b 100644 --- a/lib/getOriginConfig.js +++ b/lib/getOriginConfig.js @@ -1,9 +1,9 @@ const url = require('url') -module.exports = (origin) => { +module.exports = (origin, { originAccessIdentityId = '' }) => { const originUrl = typeof origin === 'string' ? origin : origin.url - const { hostname } = url.parse(originUrl) + const { hostname, pathname } = url.parse(originUrl) const originConfig = { Id: hostname, @@ -12,7 +12,7 @@ module.exports = (origin) => { Quantity: 0, Items: [] }, - OriginPath: '' + OriginPath: pathname === '/' ? '' : pathname } if (originUrl.includes('s3')) { @@ -20,7 +20,9 @@ module.exports = (origin) => { originConfig.Id = bucketName originConfig.DomainName = `${bucketName}.s3.amazonaws.com` originConfig.S3OriginConfig = { - OriginAccessIdentity: '' + OriginAccessIdentity: originAccessIdentityId + ? `origin-access-identity/cloudfront/${originAccessIdentityId}` + : '' } } else { originConfig.CustomOriginConfig = { diff --git a/lib/grantCloudFrontBucketAccess.js b/lib/grantCloudFrontBucketAccess.js new file mode 100644 index 0000000..7cd93fb --- /dev/null +++ b/lib/grantCloudFrontBucketAccess.js @@ -0,0 +1,24 @@ +module.exports = (s3, bucketName, s3CanonicalUserId) => { + const policy = ` + { + "Version":"2012-10-17", + "Id":"PolicyForCloudFrontPrivateContent", + "Statement":[ + { + "Sid":" Grant a CloudFront Origin Identity access to support private content", + "Effect":"Allow", + "Principal":{"CanonicalUser":"${s3CanonicalUserId}"}, + "Action":"s3:GetObject", + "Resource":"arn:aws:s3:::${bucketName}/*" + } + ] + } + ` + + return s3 + .putBucketPolicy({ + Bucket: bucketName, + Policy: policy.replace(/(\r\n|\n|\r|\t)/gm, '') + }) + .promise() +} diff --git a/lib/index.js b/lib/index.js index b883bd2..bd64ccd 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,7 +1,25 @@ const parseInputOrigins = require('./parseInputOrigins') const getDefaultCacheBehavior = require('./getDefaultCacheBehavior') +const createOriginAccessIdentity = require('./createOriginAccessIdentity') +const grantCloudFrontBucketAccess = require('./grantCloudFrontBucketAccess') + +const servePrivateContentEnabled = (inputs) => + inputs.origins.some((origin) => { + return origin && origin.private === true + }) + +const updateBucketsPolicies = async (s3, origins, s3CanonicalUserId) => { + // update bucket policies with cloudfront access + const bucketNames = origins.Items.filter((origin) => origin.S3OriginConfig).map( + (origin) => origin.Id + ) + + return Promise.all( + bucketNames.map((bucketName) => grantCloudFrontBucketAccess(s3, bucketName, s3CanonicalUserId)) + ) +} -const createCloudFrontDistribution = async (cf, inputs) => { +const createCloudFrontDistribution = async (cf, s3, inputs) => { const params = { DistributionConfig: { CallerReference: String(Date.now()), @@ -22,7 +40,18 @@ const createCloudFrontDistribution = async (cf, inputs) => { const distributionConfig = params.DistributionConfig - const { Origins, CacheBehaviors } = parseInputOrigins(inputs.origins) + let originAccessIdentityId + let s3CanonicalUserId + + if (servePrivateContentEnabled(inputs)) { + ;({ originAccessIdentityId, s3CanonicalUserId } = await createOriginAccessIdentity(cf)) + } + + const { Origins, CacheBehaviors } = parseInputOrigins(inputs.origins, { originAccessIdentityId }) + + if (s3CanonicalUserId) { + await updateBucketsPolicies(s3, Origins, s3CanonicalUserId) + } distributionConfig.Origins = Origins @@ -42,7 +71,7 @@ const createCloudFrontDistribution = async (cf, inputs) => { } } -const updateCloudFrontDistribution = async (cf, distributionId, inputs) => { +const updateCloudFrontDistribution = async (cf, s3, distributionId, inputs) => { // Update logic is a bit weird... // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CloudFront.html#updateDistribution-property @@ -63,7 +92,21 @@ const updateCloudFrontDistribution = async (cf, distributionId, inputs) => { params.DistributionConfig.Enabled = inputs.enabled === false ? false : true - const { Origins, CacheBehaviors } = parseInputOrigins(inputs.origins) + let s3CanonicalUserId + let originAccessIdentityId + + if (servePrivateContentEnabled(inputs)) { + // presumably it's ok to call create origin access identity again + // aws api returns cached copy of what was previously created + // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CloudFront.html#createCloudFrontOriginAccessIdentity-property + ;({ originAccessIdentityId, s3CanonicalUserId } = await createOriginAccessIdentity(cf)) + } + + const { Origins, CacheBehaviors } = parseInputOrigins(inputs.origins, { originAccessIdentityId }) + + if (s3CanonicalUserId) { + await updateBucketsPolicies(s3, Origins, s3CanonicalUserId) + } params.DistributionConfig.DefaultCacheBehavior = getDefaultCacheBehavior(Origins.Items[0].Id) params.DistributionConfig.Origins = Origins diff --git a/lib/parseInputOrigins.js b/lib/parseInputOrigins.js index 4021dd1..4052b66 100644 --- a/lib/parseInputOrigins.js +++ b/lib/parseInputOrigins.js @@ -7,15 +7,19 @@ const validLambdaTriggers = [ 'origin-response', 'viewer-response' ] -module.exports = (origins) => { +module.exports = (origins, options) => { const distributionOrigins = { Quantity: 0, Items: [] } - let distributionCacheBehaviors + + const distributionCacheBehaviors = { + Quantity: 0, + Items: [] + } for (const origin of origins) { - const originConfig = getOriginConfig(origin) + const originConfig = getOriginConfig(origin, options) distributionOrigins.Quantity = distributionOrigins.Quantity + 1 distributionOrigins.Items.push(originConfig) @@ -44,11 +48,6 @@ module.exports = (origins) => { }) }) - distributionCacheBehaviors = { - Quantity: 0, - Items: [] - } - distributionCacheBehaviors.Quantity = distributionCacheBehaviors.Quantity + 1 distributionCacheBehaviors.Items.push(cacheBehavior) } diff --git a/serverless.js b/serverless.js index 07859cc..1d47918 100644 --- a/serverless.js +++ b/serverless.js @@ -30,14 +30,19 @@ class CloudFront extends Component { region: inputs.region }) + const s3 = new aws.S3({ + credentials: this.context.credentials.aws, + region: inputs.region + }) + if (this.state.id) { if (!equals(this.state.origins, inputs.origins)) { this.context.debug(`Updating CloudFront distribution of ID ${this.state.id}.`) - this.state = await updateCloudFrontDistribution(cf, this.state.id, inputs) + this.state = await updateCloudFrontDistribution(cf, s3, this.state.id, inputs) } } else { this.context.debug(`Creating CloudFront distribution in the ${inputs.region} region.`) - this.state = await createCloudFrontDistribution(cf, inputs) + this.state = await createCloudFrontDistribution(cf, s3, inputs) } this.state.region = inputs.region