Skip to content

Commit a709298

Browse files
authored
Merge pull request #443 from getlift/storage-additions
Add `allowAcl` and `cors` options to the storage construct
2 parents fbbd32c + 1e4d3df commit a709298

8 files changed

Lines changed: 182 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,4 @@ jobs:
6363
key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }}
6464
- run: npm i
6565
- name: Typescript checks
66-
run: tsc --noEmit
66+
run: npx tsc --noEmit

docs/storage.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,53 @@ The configuration maps directly to [CloudFormation LifecycleConfiguration Rules]
113113

114114
These rules are added to the default lifecycle rules that Lift adds (intelligent tiering and old version cleanup).
115115

116+
### ACL support
117+
118+
Since April 2023, S3 buckets have ACLs disabled by default. However, many tools and libraries (including PHP's [Flysystem](https://github.com/thephpleague/flysystem), used by Laravel) send ACL headers on S3 operations. Without enabling ACLs, these operations will fail.
119+
120+
To let the bucket accept ACL headers while keeping the bucket owner in full control:
121+
122+
```yaml
123+
constructs:
124+
storage:
125+
type: storage
126+
allowAcl: true
127+
```
128+
129+
This sets the S3 bucket's [Object Ownership](https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html) to `BucketOwnerPreferred` and grants `s3:GetObjectAcl` and `s3:PutObjectAcl` permissions to Lambda functions.
130+
131+
### CORS
132+
133+
To allow browser-based uploads (e.g. via presigned URLs), you can configure CORS on the bucket.
134+
135+
**Simple form** — allow a single origin with default methods (`GET`, `PUT`, `DELETE`) and all headers:
136+
137+
```yaml
138+
constructs:
139+
storage:
140+
type: storage
141+
cors: "${construct:website.url}"
142+
```
143+
144+
Use `cors: "*"` to allow all origins.
145+
146+
**Full form** — define complete CORS rules (property names can be camelCase or PascalCase):
147+
148+
```yaml
149+
constructs:
150+
storage:
151+
type: storage
152+
cors:
153+
- allowedOrigins:
154+
- "${construct:website.url}"
155+
allowedMethods:
156+
- PUT
157+
allowedHeaders:
158+
- "*"
159+
```
160+
161+
The full form maps directly to [CloudFormation CorsRules](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3-bucket-corsconfiguration-corsrule.html).
162+
116163
## Extensions
117164
118165
You can specify an `extensions` property on the storage construct to extend the underlying CloudFormation resources. In the exemple below, the S3 Bucket CloudFormation resource generated by the `avatars` storage construct will be extended with the new `AccessControl: PublicRead` CloudFormation property.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"chalk": "^4.1.1",
1010
"change-case": "^4.1.2",
1111
"cidr-split": "^0.1.2",
12-
"constructs": "10.2.20",
12+
"constructs": "^10.5.0",
1313
"inquirer": "^8.2.7",
1414
"js-yaml": "^3.14.2",
1515
"lodash": "^4.17.21",

src/constructs/aws/Storage.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@ const STORAGE_DEFINITION = {
1616
encryption: {
1717
anyOf: [{ const: "s3" }, { const: "kms" }],
1818
},
19+
allowAcl: { type: "boolean" },
20+
cors: {
21+
anyOf: [{ type: "array", items: { type: "object" } }, { type: "string" }],
22+
},
1923
lifecycleRules: {
2024
type: "array",
2125
items: { type: "object" },
2226
},
2327
},
2428
additionalProperties: false,
2529
} as const;
26-
const STORAGE_DEFAULTS: Required<FromSchema<typeof STORAGE_DEFINITION>> = {
30+
const STORAGE_DEFAULTS: Omit<Required<FromSchema<typeof STORAGE_DEFINITION>>, "allowAcl" | "cors"> = {
2731
type: "storage",
2832
archive: 45,
2933
encryption: "s3",
@@ -59,13 +63,15 @@ export class Storage extends AwsConstruct {
5963
public static schema = STORAGE_DEFINITION;
6064

6165
private readonly bucket: Bucket;
66+
private readonly allowAcl: boolean;
6267
// a remplacer par StorageExtensionsKeys
6368
private readonly bucketNameOutput: CfnOutput;
6469

6570
constructor(scope: CdkConstruct, id: string, configuration: Configuration, private provider: AwsProvider) {
6671
super(scope, id);
6772

6873
const resolvedConfiguration = Object.assign({}, STORAGE_DEFAULTS, configuration);
74+
this.allowAcl = resolvedConfiguration.allowAcl === true;
6975

7076
const encryptionOptions = {
7177
s3: BucketEncryption.S3_MANAGED,
@@ -113,6 +119,28 @@ export class Storage extends AwsConstruct {
113119
Rules: [...defaultRules, ...userRules],
114120
});
115121

122+
if (this.allowAcl) {
123+
cfnBucket.addPropertyOverride("OwnershipControls", {
124+
Rules: [{ ObjectOwnership: "BucketOwnerPreferred" }],
125+
});
126+
}
127+
128+
if (resolvedConfiguration.cors !== undefined) {
129+
let corsRules;
130+
if (typeof resolvedConfiguration.cors === "string") {
131+
corsRules = [
132+
{
133+
AllowedOrigins: [resolvedConfiguration.cors],
134+
AllowedMethods: ["GET", "PUT", "DELETE"],
135+
AllowedHeaders: ["*"],
136+
},
137+
];
138+
} else {
139+
corsRules = resolvedConfiguration.cors.map((rule) => capitalizeKeys(rule as Record<string, unknown>));
140+
}
141+
cfnBucket.addPropertyOverride("CorsConfiguration", { CorsRules: corsRules });
142+
}
143+
116144
this.bucketNameOutput = new CfnOutput(this, "BucketName", {
117145
value: this.bucket.bucketName,
118146
});
@@ -126,11 +154,16 @@ export class Storage extends AwsConstruct {
126154
}
127155

128156
permissions(): PolicyStatement[] {
157+
const actions = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"];
158+
if (this.allowAcl) {
159+
actions.push("s3:GetObjectAcl", "s3:PutObjectAcl");
160+
}
161+
129162
return [
130-
new PolicyStatement(
131-
["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
132-
[this.bucket.bucketArn, Stack.of(this).resolve(Fn.join("/", [this.bucket.bucketArn, "*"]))]
133-
),
163+
new PolicyStatement(actions, [
164+
this.bucket.bucketArn,
165+
Stack.of(this).resolve(Fn.join("/", [this.bucket.bucketArn, "*"])),
166+
]),
134167
];
135168
}
136169

test/fixtures/permissions/serverless.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ functions:
1313
constructs:
1414
testStorage:
1515
type: storage
16+
allowAcl: true
17+
testStorageNoAcl:
18+
type: storage

test/fixtures/storage/serverless.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ constructs:
3232
- HEAD
3333
- PUT
3434
- POST
35+
withAcl:
36+
type: storage
37+
allowAcl: true
38+
withCorsString:
39+
type: storage
40+
cors: "*"
41+
withCorsRules:
42+
type: storage
43+
cors:
44+
- allowedOrigins:
45+
- "https://example.com"
46+
allowedMethods:
47+
- PUT
48+
allowedHeaders:
49+
- "*"
3550
withLifecycleRules:
3651
type: storage
3752
lifecycleRules:

test/unit/permissions.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@ function expectLiftStorageStatementIsAdded(cfTemplate: CfTemplate) {
1111
expect.arrayContaining([
1212
expect.objectContaining({
1313
Effect: "Allow",
14-
Action: ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
14+
Action: [
15+
"s3:PutObject",
16+
"s3:GetObject",
17+
"s3:DeleteObject",
18+
"s3:ListBucket",
19+
"s3:GetObjectAcl",
20+
"s3:PutObjectAcl",
21+
],
1522
}),
1623
])
1724
);
@@ -103,6 +110,27 @@ describe("permissions", () => {
103110
expectLiftStorageStatementIsAdded(cfTemplate);
104111
});
105112

113+
it("should not include ACL permissions when allowAcl is not set", async () => {
114+
const { cfTemplate } = await runServerless({
115+
fixture: "permissions",
116+
configExt: pluginConfigExt,
117+
command: "package",
118+
});
119+
const statements = get(
120+
cfTemplate.Resources.IamRoleLambdaExecution,
121+
"Properties.Policies[0].PolicyDocument.Statement"
122+
) as unknown as { Action: string[] }[];
123+
// testStorageNoAcl should produce a statement without ACL permissions
124+
expect(statements).toEqual(
125+
expect.arrayContaining([
126+
expect.objectContaining({
127+
Effect: "Allow",
128+
Action: ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
129+
}),
130+
])
131+
);
132+
});
133+
106134
it("should be possible to disable automatic permissions", async () => {
107135
const { cfTemplate } = await runServerless({
108136
fixture: "permissions",

test/unit/storage.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,54 @@ describe("storage", () => {
7777
});
7878
});
7979

80+
it("should not set OwnershipControls by default", () => {
81+
expect(cfTemplate.Resources[computeLogicalId("default", "Bucket")].Properties).not.toHaveProperty(
82+
"OwnershipControls"
83+
);
84+
});
85+
86+
it("should set OwnershipControls when allowAcl is true", () => {
87+
expect(cfTemplate.Resources[computeLogicalId("withAcl", "Bucket")].Properties).toMatchObject({
88+
OwnershipControls: {
89+
Rules: [{ ObjectOwnership: "BucketOwnerPreferred" }],
90+
},
91+
});
92+
});
93+
94+
it("should not set CorsConfiguration by default", () => {
95+
expect(cfTemplate.Resources[computeLogicalId("default", "Bucket")].Properties).not.toHaveProperty(
96+
"CorsConfiguration"
97+
);
98+
});
99+
100+
it("should configure CORS with default methods when cors is a string", () => {
101+
expect(cfTemplate.Resources[computeLogicalId("withCorsString", "Bucket")].Properties).toMatchObject({
102+
CorsConfiguration: {
103+
CorsRules: [
104+
{
105+
AllowedOrigins: ["*"],
106+
AllowedMethods: ["GET", "PUT", "DELETE"],
107+
AllowedHeaders: ["*"],
108+
},
109+
],
110+
},
111+
});
112+
});
113+
114+
it("should configure CORS with full rules when cors is an array", () => {
115+
expect(cfTemplate.Resources[computeLogicalId("withCorsRules", "Bucket")].Properties).toMatchObject({
116+
CorsConfiguration: {
117+
CorsRules: [
118+
{
119+
AllowedOrigins: ["https://example.com"],
120+
AllowedMethods: ["PUT"],
121+
AllowedHeaders: ["*"],
122+
},
123+
],
124+
},
125+
});
126+
});
127+
80128
it("supports custom lifecycleRules with auto-capitalization and default Status", () => {
81129
const lifecycleConfig = cfTemplate.Resources[computeLogicalId("withLifecycleRules", "Bucket")].Properties
82130
.LifecycleConfiguration as { Rules: unknown[] };

0 commit comments

Comments
 (0)