Mission 4.4 · Stage 4 — Data & Secrets
Private Uploads with a Least-Privilege Task Role
Goal: A private uploads bucket that clients write to directly with presigned URLs issued by ShopLite 1.3.0, where the task role can touch exactly one prefix of one bucket and non-TLS requests are denied.
By the end of this mission
- Write an S3 bucket policy that denies insecure transport
- Scope a task role's IAM policy to one bucket prefix
- Configure CORS and lifecycle rules as code
- Test presigned uploads end to end with curl
Part 1
Understand it first
Identity policy vs bucket policy, together
The TASK ROLE's identity policy says what ShopLite may do: put and get objects under uploads/* in one bucket. The BUCKET policy says what the bucket accepts from anyone: nothing over plain HTTP. Both are evaluated, and an explicit deny in either wins (AWS course, Topic 1.1). Least privilege isn't one policy; it's each layer allowing only what's needed.
Presigned URLs inherit the signer's permissions
A presigned URL is a request signed with the TASK ROLE's credentials, handed to a client. When the client uses it, S3 evaluates the task role's permissions at that moment. So the role must allow s3:PutObject on the target key, a URL for a key outside uploads/* fails even though ShopLite signed it, and URLs signed with temporary role credentials expire when those credentials do (at most the session lifetime), whatever expiresIn says.
Object ARNs vs bucket ARNs
Bucket-level actions like s3:ListBucket apply to arn:aws:s3:::bucket; object-level actions like s3:PutObject apply to arn:aws:s3:::bucket/key. Putting object actions on the bucket ARN silently grants nothing. This mistake shows up in real IAM reviews constantly, and you'll trigger it deliberately below.
Part 2
Your project after this mission · 7 files change
- app/
- package.jsonmodified
- server.jsmodified
- infra/
- alb.tf
- autoscaling.tf
- backend.tf
- ecr.tf
- ecs.tfmodified
- iam.tfmodified
- locals.tf
- logs.tf
- network.tf
- outputs.tfmodified
- probe.tf
- providers.tf
- refactors.tf
- security.tf
- storage.tf
- terraform.tfvarsmodified
- uploads.tfnew
- variables.tf
- versions.tf
Part 3
Build it, step by step
- 1
The uploads bucket
Same baseline as the assets bucket, plus: CORS allowing browsers to PUT directly (tighten
allowed_originsto your real frontend domain), a lifecycle rule that deletes abandoned multipart uploads, and a bucket policy denying any request without TLS.infra/uploads.tfwhole filehcl resource "aws_s3_bucket" "uploads" { bucket = "${local.name_prefix}-uploads-${random_id.suffix.hex}" force_destroy = var.environment != "prod" } resource "aws_s3_bucket_public_access_block" "uploads" { bucket = aws_s3_bucket.uploads.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } resource "aws_s3_bucket_cors_configuration" "uploads" { bucket = aws_s3_bucket.uploads.id cors_rule { allowed_methods = ["PUT"] allowed_origins = ["*"] # tighten to your frontend's origin allowed_headers = ["content-type"] max_age_seconds = 3000 } } resource "aws_s3_bucket_lifecycle_configuration" "uploads" { bucket = aws_s3_bucket.uploads.id rule { id = "abort-incomplete-multipart" status = "Enabled" filter {} abort_incomplete_multipart_upload { days_after_initiation = 1 } } } data "aws_iam_policy_document" "uploads_bucket" { statement { sid = "DenyInsecureTransport" effect = "Deny" actions = ["s3:*"] resources = [ aws_s3_bucket.uploads.arn, "${aws_s3_bucket.uploads.arn}/*", ] principals { type = "*" identifiers = ["*"] } condition { test = "Bool" variable = "aws:SecureTransport" values = ["false"] } } } resource "aws_s3_bucket_policy" "uploads" { bucket = aws_s3_bucket.uploads.id policy = data.aws_iam_policy_document.uploads_bucket.json # Block Public Access must exist first, or a policy change could briefly be evaluated without it. depends_on = [aws_s3_bucket_public_access_block.uploads] } - 2
Scope the task role to one prefix
Extend the task role's policy document with a second statement. Object actions go on
<bucket-arn>/uploads/*, not on the bucket ARN and not on*.infra/iam.tfadd to filehcl Add a second statement inside data "aws_iam_policy_document" "api_task".
statement { sid = "UploadsPrefixOnly" actions = ["s3:PutObject", "s3:GetObject"] resources = ["${aws_s3_bucket.uploads.arn}/uploads/*"] } - 3
Presigned URLs in ShopLite 1.3.0
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner. The SDK picks up the task role's credentials automatically from the ECS container credentials endpoint, with no keys anywhere. Keys get a random prefix so users can't overwrite each other's files.app/server.jsadd to filejs import crypto from "node:crypto"; import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const s3 = new S3Client({}); // inside the handler, before the fallback: if (req.method === "POST" && req.url === "/uploads") { const body = await new Promise((r) => { let d = ""; req.on("data", (c) => (d += c)); req.on("end", () => r(d)); }); const { filename = "file", contentType = "application/octet-stream" } = JSON.parse(body || "{}"); const key = `uploads/${crypto.randomUUID()}-${filename.replace(/[^\w.-]/g, "_")}`; const url = await getSignedUrl( s3, new PutObjectCommand({ Bucket: process.env.UPLOADS_BUCKET, Key: key, ContentType: contentType }), { expiresIn: 300 }, ); return json(res, 201, { key, url }); } - 4
Tell the container which bucket, then deploy
Add
{ name = "UPLOADS_BUCKET", value = aws_s3_bucket.uploads.bucket }to the container'senvironment, plus anuploads_bucketoutput. Build and push1.3.0, set it in tfvars, and apply.terminal$ docker buildx build --platform linux/arm64 -t "$REPO:1.3.0" --push ../appterraform apply── expected output ──Plan: 6 to add, 2 to change, 1 to destroy....Apply complete! Resources: 6 added, 2 changed, 1 destroyed. - 5
Upload end to end
Ask ShopLite for a URL, then PUT a file straight to S3 with it. The
Content-Typeheader must match what was signed. Then confirm the object exists.terminal$ URL=$(terraform output -raw api_url)RESP=$(curl -s -X POST "$URL/uploads" -H 'content-type: application/json' -d '{"filename":"logo.png","contentType":"image/png"}')curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'content-type: image/png' --upload-file ./logo.png "$(echo $RESP | jq -r .url)"aws s3 ls "s3://$(terraform output -raw uploads_bucket)/uploads/"── expected output ──2002026-09-26 14:02:51 4821 3c9b1f0e-8a2d-4f6b-9e41-7d20c3a5b6e8-logo.png
Checkpoint — you should now have
- ✓The uploads bucket is private, TLS-only, with CORS and multipart cleanup.
- ✓The task role can put/get only
uploads/*in that bucket, and nothing else in S3. - ✓A presigned PUT from curl succeeds and the object appears under
uploads/. - ✓ShopLite
1.3.0is pinned in tfvars.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Grant object actions on the bucket ARN
Change the statement's resource to aws_s3_bucket.uploads.arn (drop /uploads/*), apply, request a new URL, and upload.
Break #2
Use the presigned URL over plain HTTP
Take a fresh URL and replace https:// with http:// before uploading.
Part 5
Interview questions from this mission
An app generates presigned S3 URLs but uploads fail with AccessDenied. What do you check?
How do you enforce HTTPS-only access to an S3 bucket?
Why scope an application role to a prefix like uploads/* rather than the whole bucket?