TT Lab
Get started
Learn Learning paths Courses

Object Storage and S3

Access Control With Users and Policies

Continue in TT Lab

Goal

By creating a service user and writing and attaching a least-privilege policy yourself, build the habit of narrowing the permission scope by prefix.

Why it matters

Most storage leak incidents come not from complex attacks but from permissions left open broadly. A bucket is opened to anonymous reads with "it only has images," and months later backups or user uploads get mixed in under that bucket. Setting the policy's Resource to the whole bucket (arn:aws:s3:::bucket/*) is the same problem — if the credentials of a single service leak, the whole bucket is exposed. If you narrow it to the prefix, the damage is confined within that prefix. And this is also a defense against implementation bugs. MinIO's CVE-2025-62506 was a vulnerability in which privileges escalated through a session policy bypass. The habit of writing policies narrowly is the last line of defense in such a moment.

This lab uses the same IAM API as AWS (aws iam ...). The server is SeaweedFS's S3 gateway, and it accepts users, access keys, managed policies and attachments with the same calls as AWS. So the commands and policy documents used here can be taken to an AWS account as they are.

Steps

  1. With the administrator profile local, run aws iam create-user --user-name appuser, and put the key received from aws iam create-access-key --user-name appuser into the profile app (along with endpoint_url and region).
  2. Try aws --profile app s3 ls s3://lab-media and save the result to /root/pol/denied.txt. The file must contain AccessDenied.
  3. Write a policy in /root/pol/readonly.json. It allows s3:GetObject and s3:ListBucket, and Resource has two entries, arn:aws:s3:::lab-media and arn:aws:s3:::lab-media/*.
  4. Create a managed policy with aws iam create-policy --policy-name labreadonly --policy-document file:///root/pol/readonly.json, and attach it to appuser with aws iam attach-user-policy. aws --profile app s3 ls s3://lab-media must succeed.
  5. aws --profile app s3 cp /opt/fixtures/s3/readme.txt s3://lab-media/doc/nope.txt must fail. In /root/pol/write.txt, write write_denied=true.
  6. In /root/pol/imgonly.json, write a policy with Resource narrowed to arn:aws:s3:::lab-media/img/*, create it as labimgonly and attach it (detach the broad labreadonly). As appuser, looking up img/logo.png must work and looking up doc/sales.csv must fail. In /root/pol/prefix.txt, write img=ok doc=denied.
  7. With a bucket policy, open lab-media to anonymous download for only the public/ prefix. An anonymous GET must return 200 for objects under public/ and 403 for objects under private/. In /root/pol/anon.txt, write public=200 private=403.

Notes

Create a service user

With the administrator profile local, run aws iam create-user --user-name appuser, and put the key received from aws iam create-access-key --user-name appuser into the profile app (along with endpoint_url and region).

Create the user with the administrator profile and issue an access key. The secret is shown only once in the issuance response, so put it into the profile right away.

Check that a user with no policy is denied

Try aws --profile app s3 ls s3://lab-media and save the result to /root/pol/denied.txt. The file must contain AccessDenied.

If you try a listing with the new profile, it is denied. The default is having no permissions at all.

Write a read-only policy

Write a policy in /root/pol/readonly.json. It allows s3:GetObject and s3:ListBucket, and Resource has two entries, arn:aws:s3:::lab-media and arn:aws:s3:::lab-media/*.

Write Effect, Action and Resource in JSON. Listing and reading objects are different Actions.

Create and attach a managed policy to make reads succeed

Create a managed policy with aws iam create-policy --policy-name labreadonly --policy-document file:///root/pol/readonly.json, and attach it to appuser with aws iam attach-user-policy. aws --profile app s3 ls s3://lab-media must succeed.

Creating the policy and attaching it to the user are separate steps. Right after attaching, it can take a few seconds to take effect.

Check that writes are still denied

aws --profile app s3 cp /opt/fixtures/s3/readme.txt s3://lab-media/doc/nope.txt must fail. In /root/pol/write.txt, write write_denied=true.

With a read-only policy, the upload must fail. Failing is the normal result.

Create a prefix-limited policy

In /root/pol/imgonly.json, write a policy with Resource narrowed to arn:aws:s3:::lab-media/img/*, create it as labimgonly and attach it (detach the broad labreadonly). As appuser, looking up img/logo.png must work and looking up doc/sales.csv must fail. In /root/pol/prefix.txt, write img=ok doc=denied.

Attach the wildcard to the prefix in Resource. Make img/ work and doc/ not work.

Publicly open only a specific prefix

With a bucket policy, open lab-media to anonymous download for only the public/ prefix. An anonymous GET must return 200 for objects under public/ and 403 for objects under private/. In /root/pol/anon.txt, write public=200 private=403.

Opening the whole bucket and opening a single prefix differ in the scale of an incident.

Be careful with the single slash character at the end of Resource. The whole point of this lab is in it.

"Resource": "arn:aws:s3:::lab-media/public*"    → public 으로 시작하는 모든 키
"Resource": "arn:aws:s3:::lab-media/public/*"   → public/ 아래 키만

S3 has no folders. What a policy sees is only the string prefix, so if you leave out the slash, even root objects such as public-backup.tar, which merely start with public, are opened together. Check the Resource actually applied on the server with aws s3api get-bucket-policy --bucket lab-media.

A bucket has one bucket policy, and every put-bucket-policy replaces it entirely. When you change the scope, put in the whole document again.