Access Control With Users and Policies
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
- With the administrator profile
local, runaws iam create-user --user-name appuser, and put the key received fromaws iam create-access-key --user-name appuserinto the profileapp(along withendpoint_urlandregion). - Try
aws --profile app s3 ls s3://lab-mediaand save the result to/root/pol/denied.txt. The file must containAccessDenied. - Write a policy in
/root/pol/readonly.json. It allowss3:GetObjectands3:ListBucket, andResourcehas two entries,arn:aws:s3:::lab-mediaandarn:aws:s3:::lab-media/*. - Create a managed policy with
aws iam create-policy --policy-name labreadonly --policy-document file:///root/pol/readonly.json, and attach it to appuser withaws iam attach-user-policy.aws --profile app s3 ls s3://lab-mediamust succeed. aws --profile app s3 cp /opt/fixtures/s3/readme.txt s3://lab-media/doc/nope.txtmust fail. In/root/pol/write.txt, writewrite_denied=true.- In
/root/pol/imgonly.json, write a policy withResourcenarrowed toarn:aws:s3:::lab-media/img/*, create it aslabimgonlyand attach it (detach the broadlabreadonly). As appuser, looking upimg/logo.pngmust work and looking updoc/sales.csvmust fail. In/root/pol/prefix.txt, writeimg=ok doc=denied. - With a bucket policy, open
lab-mediato anonymous download for only thepublic/prefix. An anonymous GET must return 200 for objects underpublic/and 403 for objects underprivate/. In/root/pol/anon.txt, writepublic=200 private=403.
Notes
- Users and keys:
aws iam list-users·aws iam list-access-keys --user-name appuser - Listing policies and checking attachment:
aws iam list-policies·aws iam list-attached-user-policies --user-name appuser - Do not write policy ARNs by hand; find them with
list-policies. AWS includes the account number (arn:aws:iam::123456789012:policy/…) and this server leaves it empty (arn:aws:iam:::policy/…), so the formats differ. - If you run
create-policyagain with the same name, you getEntityAlreadyExists. To fix the document, useaws iam create-policy-version --set-as-default. - Bucket policy:
aws s3api put-bucket-policy --bucket lab-media --policy file://…· to check,get-bucket-policy - IAM changes may not take effect immediately. AWS also takes a few seconds, so it is safer to retry a few times when checking right after attaching.
- Limitation of this server: an inline policy put directly on a user (
put-user-policy) accepts onlyAllow(DenygivesMalformedPolicyDocument). Managed policies and bucket policies also acceptDeny, so this lab uses the managed policies that AWS recommends. - Common mistake 1: leaving out
ListBucket— the objects can be read but the list does not appear. They are different Actions and the Resource format is different too. - Common mistake 2: setting
Resourceto the whole bucket — if a single credential leaks, everything is exposed.
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.