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: Add private bucket option #58

Merged
merged 7 commits into from
May 28, 2024
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ aws-spa deploy app.example.com/$(git branch | grep * | cut -d ' ' -f2)
- `--cacheInvalidation`: cache invalidation to be done in CloudFront. Default is `*`: all files are invalidated. For a `create-react-app` app you only need to invalidate `/index.html`
- `--cacheBustedPrefix`: a folder where files are suffixed with a hash (cash busting). Their `cache-control` value is set to `max-age=31536000`. For a `create-react-app` app you can specify `static/`.
- `--noPrompt`: Disable confirm message that prompts on non CI environments (env CI=true).
- `--shouldBlockBucketPublicAccess`: This option will deploy the SPA with a bucket not being publicly accessible. Access to the bucket will be done through an Origin Access Control (OAC).

## Migrate an existing SPA on aws-spa

Expand Down
17 changes: 13 additions & 4 deletions src/aws-services.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { S3, ACM, CloudFront, Route53, Lambda, IAM } from "aws-sdk";

// Bucket region must be fixed so that website endpoint is fixe
// https://docs.aws.amazon.com/fr_fr/general/latest/gr/rande.html#s3_website_region_endpoints
// https://docs.aws.amazon.com/fr_fr/general/latest/gr/s3.html
export const bucketRegion = "eu-west-3";

export const s3 = new S3({
apiVersion: "2006-03-01",
region: bucketRegion
region: bucketRegion,
});

export const lambda = new Lambda({ region: "us-east-1" });
Expand All @@ -20,7 +20,7 @@ export const cloudfront = new CloudFront();
export const route53 = new Route53();

// S3 API does not seem to expose this data
// https://docs.aws.amazon.com/fr_fr/general/latest/gr/rande.html#s3_website_region_endpoints
// https://docs.aws.amazon.com/fr_fr/general/latest/gr/s3.html
export const websiteEndpoint = {
"us-east-2": "s3-website.us-east-2.amazonaws.com",
"us-east-1": "s3-website-us-east-1.amazonaws.com",
Expand All @@ -39,5 +39,14 @@ export const websiteEndpoint = {
"eu-west-2": "s3-website.eu-west-2.amazonaws.com",
"eu-west-3": "s3-website.eu-west-3.amazonaws.com",
"eu-north-1": "s3-website.eu-north-1.amazonaws.com",
"sa-east-1": "s3-website-sa-east-1.amazonaws.com"
"sa-east-1": "s3-website-sa-east-1.amazonaws.com",
};

export const getS3DomainNameForBlockedBucket = (domainName: string) =>
`${domainName}.s3.${bucketRegion}.amazonaws.com`;

export const getS3DomainName = (domainName: string) =>
`${domainName}.${websiteEndpoint[bucketRegion]}`;

export const getOriginId = (domainName: string) =>
`S3-Website-${getS3DomainName(domainName)}`;
10 changes: 9 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ yargs
default: false,
describe:
"Disable confirm message that prompts on non CI environments (env CI=true)",
})
.option("shouldBlockBucketPublicAccess", {
type: "boolean",
default: false,
describe: `Use a REST API endpoint as the origin, and restrict access with an OAC".

This is useful if you want to keep your bucket private. This would not work for multiple versions hosted in the same s3 bucket.`,
});
},
async (argv) => {
Expand All @@ -65,7 +72,8 @@ yargs
argv.cacheInvalidation,
argv.cacheBustedPrefix,
argv.credentials || process.env.AWS_SPA_CREDENTIALS,
argv.noPrompt
argv.noPrompt,
argv.shouldBlockBucketPublicAccess
);
logger.info("✅ done!");
process.exit(0);
Expand Down
210 changes: 157 additions & 53 deletions src/cloudfront.spec.ts → src/cloudfront/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { cloudfront } from "./aws-services";
import { awsReject, awsResolve } from "./test-helper";
import {
createCloudFrontDistribution,
findDeployedCloudfrontDistribution,
invalidateCloudfrontCache,
getCacheInvalidations,
identifyingTag,
createCloudFrontDistribution,
invalidateCloudfrontCache,
invalidateCloudfrontCacheWithRetry,
setSimpleAuthBehavior,
getCacheInvalidations,
invalidateCloudfrontCacheWithRetry
} from "./cloudfront";
import { lambdaPrefix } from "./lambda";
updateCloudFrontDistribution,
} from ".";
import {
cloudfront,
getS3DomainName,
getS3DomainNameForBlockedBucket,
} from "../aws-services";
import { lambdaPrefix } from "../lambda";
import { awsReject, awsResolve } from "../test-helper";

describe("cloudfront", () => {
describe("findDeployedCloudfrontDistribution", () => {
Expand All @@ -36,11 +41,11 @@ describe("cloudfront", () => {
{
Id: "GOODBYE",
Aliases: {
Items: ["goodbye.example.com"]
}
}
]
}
Items: ["goodbye.example.com"],
},
},
],
},
})
)
.mockReturnValueOnce(
Expand All @@ -51,25 +56,24 @@ describe("cloudfront", () => {
Id: "HELLO",
Status: "Deployed",
Aliases: {
Items: ["hello.example.com"]
}
}
]
}
Items: ["hello.example.com"],
},
},
],
},
})
);

listTagsForResourceMock.mockReturnValue(
awsResolve({
Tags: {
Items: [identifyingTag]
}
Items: [identifyingTag],
},
})
);

const distribution: any = await findDeployedCloudfrontDistribution(
"hello.example.com"
);
const distribution: any =
await findDeployedCloudfrontDistribution("hello.example.com");
expect(distribution).toBeDefined();
expect(distribution.Id).toEqual("HELLO");
});
Expand All @@ -83,19 +87,19 @@ describe("cloudfront", () => {
Id: "HELLO",
Status: "In Progress",
Aliases: {
Items: ["hello.example.com"]
}
}
]
}
Items: ["hello.example.com"],
},
},
],
},
})
);

listTagsForResourceMock.mockReturnValue(
awsResolve({
Tags: {
Items: [identifyingTag]
}
Items: [identifyingTag],
},
})
);
waitForMock.mockReturnValue(awsResolve());
Expand Down Expand Up @@ -138,7 +142,7 @@ describe("cloudfront", () => {
expect(invalidationParams.DistributionId).toEqual("some-distribution-id");
expect(invalidationParams.InvalidationBatch.Paths.Items).toEqual([
"index.html",
"static/*"
"static/*",
]);
});

Expand Down Expand Up @@ -241,7 +245,7 @@ describe("cloudfront", () => {

expect(waitForMock).toHaveBeenCalledTimes(1);
expect(waitForMock).toHaveBeenCalledWith("distributionDeployed", {
Id: "distribution-id"
Id: "distribution-id",
});
});
});
Expand All @@ -264,11 +268,11 @@ describe("cloudfront", () => {
DistributionConfig: {
DefaultCacheBehavior: {
LambdaFunctionAssociations: {
Items: []
}
}
Items: [],
},
},
},
ETag: ""
ETag: "",
})
);
await setSimpleAuthBehavior("distribution-id", null);
Expand All @@ -281,11 +285,11 @@ describe("cloudfront", () => {
DistributionConfig: {
DefaultCacheBehavior: {
LambdaFunctionAssociations: {
Items: [{ LambdaFunctionARN: `some-arn:${lambdaPrefix}:1` }]
}
}
Items: [{ LambdaFunctionARN: `some-arn:${lambdaPrefix}:1` }],
},
},
},
ETag: ""
ETag: "",
})
);
updateDistribution.mockReturnValueOnce(awsResolve());
Expand All @@ -303,11 +307,11 @@ describe("cloudfront", () => {
DistributionConfig: {
DefaultCacheBehavior: {
LambdaFunctionAssociations: {
Items: [{ LambdaFunctionARN: `some-arn:${lambdaPrefix}:1` }]
}
}
Items: [{ LambdaFunctionARN: `some-arn:${lambdaPrefix}:1` }],
},
},
},
ETag: ""
ETag: "",
})
);
await setSimpleAuthBehavior(
Expand All @@ -323,11 +327,11 @@ describe("cloudfront", () => {
DistributionConfig: {
DefaultCacheBehavior: {
LambdaFunctionAssociations: {
Items: []
}
}
Items: [],
},
},
},
ETag: ""
ETag: "",
})
);
updateDistribution.mockReturnValueOnce(awsResolve());
Expand All @@ -340,8 +344,8 @@ describe("cloudfront", () => {
{
EventType: "viewer-request",
IncludeBody: false,
LambdaFunctionARN: "some-arn:1"
}
LambdaFunctionARN: "some-arn:1",
},
]);
});
});
Expand All @@ -353,15 +357,115 @@ describe("cloudfront", () => {
{
input: "index.html, hello.html",
subFolder: undefined,
expectedOutput: "/index.html,/hello.html"
expectedOutput: "/index.html,/hello.html",
},
{
input: "index.html",
subFolder: "some-branch",
expectedOutput: "/some-branch/index.html"
}
expectedOutput: "/some-branch/index.html",
},
])("add missing slash", ({ input, subFolder, expectedOutput }) => {
expect(getCacheInvalidations(input, subFolder)).toEqual(expectedOutput);
});
});

describe("updateCloudFrontDistribution", () => {
const getDistributionConfigMock = jest.spyOn(
cloudfront,
"getDistributionConfig"
);
const updateDistribution = jest.spyOn(cloudfront, "updateDistribution");

beforeEach(() => {
getDistributionConfigMock.mockReset();
updateDistribution.mockReset();
});

it.each([
{
shouldBlockBucketPublicAccess: true,
},
{ shouldBlockBucketPublicAccess: false },
])(
"should not update the distribution if the right origin is already associated",
async ({ shouldBlockBucketPublicAccess }) => {
const domainName = "hello.lalilo.com";
const originId = shouldBlockBucketPublicAccess
? getS3DomainNameForBlockedBucket(domainName)
: getS3DomainName(domainName);

const distribution = {
Id: "distribution-id",
Origins: { Items: [{ Id: originId }] },
DefaultCacheBehavior: {
TargetOriginId: originId,
},
};

getDistributionConfigMock.mockReturnValue(
awsResolve({ DistributionConfig: distribution })
);

await updateCloudFrontDistribution(
distribution.Id,
domainName,
shouldBlockBucketPublicAccess,
undefined
);

expect(updateDistribution).not.toHaveBeenCalled();
}
);

it("should update the distribution with an OAC when shouldBlockBucketPublicAccess and oac is given", async () => {
const shouldBlockBucketPublicAccess = true;
const domainName = "hello.lalilo.com";
const originIdForPrivateBucket =
getS3DomainNameForBlockedBucket(domainName);

const oac = { originAccessControl: { Id: "oac-id" }, ETag: "etag" };
const distribution = {
Id: "distribution-id",
Origins: { Items: [{ Id: getS3DomainName(domainName) }] },
DefaultCacheBehavior: {
TargetOriginId: getS3DomainName(domainName),
},
};

getDistributionConfigMock.mockReturnValue(
awsResolve({ DistributionConfig: distribution })
);

updateDistribution.mockReturnValueOnce(awsResolve());
await updateCloudFrontDistribution(
distribution.Id,
domainName,
shouldBlockBucketPublicAccess,
oac
);

expect(updateDistribution).toHaveBeenCalled();
expect(updateDistribution).toHaveBeenCalledWith(
expect.objectContaining({
DistributionConfig: expect.objectContaining({
Origins: expect.objectContaining({
Items: [
expect.objectContaining({
Id: originIdForPrivateBucket,
DomainName: originIdForPrivateBucket,
OriginAccessControlId: oac.originAccessControl.Id,
S3OriginConfig: {
OriginAccessIdentity: "",
},
}),
],
}),
DefaultCacheBehavior: expect.objectContaining({
TargetOriginId: originIdForPrivateBucket,
}),
}),
})
);
});
});
});
Loading